Restructure Android project from nested module layout to standard Capacitor plugin structure following community conventions. Structure Changes: - Move plugin code from android/plugin/ to android/src/main/java/ - Move test app from android/app/ to test-apps/android-test-app/app/ - Remove nested android/plugin module structure - Remove nested android/app test app structure Build Infrastructure: - Add Gradle wrapper files (gradlew, gradlew.bat, gradle/wrapper/) - Transform android/build.gradle from root project to library module - Update android/settings.gradle for standalone plugin builds - Add android/gradle.properties with AndroidX configuration - Add android/consumer-rules.pro for ProGuard rules Configuration Updates: - Add prepare script to package.json for automatic builds on npm install - Update package.json version to 1.0.1 - Update android/build.gradle to properly resolve Capacitor dependencies - Update test-apps/android-test-app/settings.gradle with correct paths - Remove android/variables.gradle (hardcode values in build.gradle) Documentation: - Update BUILDING.md with new structure and build process - Update INTEGRATION_GUIDE.md to reflect standard structure - Update README.md to remove path fix warnings - Add test-apps/BUILD_PROCESS.md documenting test app build flows Test App Configuration: - Fix android-test-app to correctly reference plugin and Capacitor - Remove capacitor-cordova-android-plugins dependency (not needed) - Update capacitor.settings.gradle path verification in fix script BREAKING CHANGE: Plugin now uses standard Capacitor Android structure. Consuming apps must update their capacitor.settings.gradle to reference android/ instead of android/plugin/. This is automatically handled by Capacitor CLI for apps using standard plugin installation.master
@ -0,0 +1,69 @@ |
|||||
|
# Building the Daily Notification Plugin |
||||
|
|
||||
|
## Important: Standalone Build Limitations |
||||
|
|
||||
|
**Capacitor plugins cannot be built standalone** because Capacitor dependencies are npm packages, not Maven artifacts. |
||||
|
|
||||
|
### ✅ Correct Way to Build |
||||
|
|
||||
|
Build the plugin **within a Capacitor app** that uses it: |
||||
|
|
||||
|
```bash |
||||
|
# In a consuming Capacitor app (e.g., test-apps/android-test-app or your app) |
||||
|
cd /path/to/capacitor-app/android |
||||
|
./gradlew assembleDebug |
||||
|
|
||||
|
# Or use Capacitor CLI |
||||
|
npx cap sync android |
||||
|
npx cap run android |
||||
|
``` |
||||
|
|
||||
|
### ❌ What Doesn't Work |
||||
|
|
||||
|
```bash |
||||
|
# This will fail - Capacitor dependencies aren't in Maven |
||||
|
cd android |
||||
|
./gradlew assembleDebug |
||||
|
``` |
||||
|
|
||||
|
### Why This Happens |
||||
|
|
||||
|
1. **Capacitor dependencies are npm packages**, not Maven artifacts |
||||
|
2. **Capacitor plugins are meant to be consumed**, not built standalone |
||||
|
3. **The consuming app provides Capacitor** as a project dependency |
||||
|
4. **When you run `npx cap sync`**, Capacitor sets up the correct dependency structure |
||||
|
|
||||
|
### For Development & Testing |
||||
|
|
||||
|
Use the test app at `test-apps/android-test-app/`: |
||||
|
|
||||
|
```bash |
||||
|
cd test-apps/android-test-app |
||||
|
npm install |
||||
|
npx cap sync android |
||||
|
cd android |
||||
|
./gradlew assembleDebug |
||||
|
``` |
||||
|
|
||||
|
The plugin will be built as part of the test app's build process. |
||||
|
|
||||
|
### Gradle Wrapper Purpose |
||||
|
|
||||
|
The gradle wrapper in `android/` is provided for: |
||||
|
- ✅ **Syntax checking** - Verify build.gradle syntax |
||||
|
- ✅ **Android Studio** - Open the plugin directory in Android Studio for editing |
||||
|
- ✅ **Documentation** - Show available tasks and structure |
||||
|
- ❌ **Not for standalone builds** - Requires a consuming app context |
||||
|
|
||||
|
### Verifying Build Configuration |
||||
|
|
||||
|
You can verify the build configuration is correct: |
||||
|
|
||||
|
```bash |
||||
|
cd android |
||||
|
./gradlew tasks # Lists available tasks (may show dependency errors, that's OK) |
||||
|
./gradlew clean # Cleans build directory |
||||
|
``` |
||||
|
|
||||
|
The dependency errors are expected - they confirm the plugin needs a consuming app context. |
||||
|
|
||||
@ -1,153 +0,0 @@ |
|||||
package com.timesafari.dailynotification |
|
||||
|
|
||||
import android.content.BroadcastReceiver |
|
||||
import android.content.Context |
|
||||
import android.content.Intent |
|
||||
import android.util.Log |
|
||||
import kotlinx.coroutines.CoroutineScope |
|
||||
import kotlinx.coroutines.Dispatchers |
|
||||
import kotlinx.coroutines.launch |
|
||||
|
|
||||
/** |
|
||||
* Boot recovery receiver to reschedule notifications after device reboot |
|
||||
* Implements RECEIVE_BOOT_COMPLETED functionality |
|
||||
* |
|
||||
* @author Matthew Raymer |
|
||||
* @version 1.1.0 |
|
||||
*/ |
|
||||
class BootReceiver : BroadcastReceiver() { |
|
||||
|
|
||||
companion object { |
|
||||
private const val TAG = "DNP-BOOT" |
|
||||
} |
|
||||
|
|
||||
override fun onReceive(context: Context, intent: Intent?) { |
|
||||
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { |
|
||||
Log.i(TAG, "Boot completed, rescheduling notifications") |
|
||||
|
|
||||
CoroutineScope(Dispatchers.IO).launch { |
|
||||
try { |
|
||||
rescheduleNotifications(context) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "Failed to reschedule notifications after boot", e) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private suspend fun rescheduleNotifications(context: Context) { |
|
||||
val db = DailyNotificationDatabase.getDatabase(context) |
|
||||
val enabledSchedules = db.scheduleDao().getEnabled() |
|
||||
|
|
||||
Log.i(TAG, "Found ${enabledSchedules.size} enabled schedules to reschedule") |
|
||||
|
|
||||
enabledSchedules.forEach { schedule -> |
|
||||
try { |
|
||||
when (schedule.kind) { |
|
||||
"fetch" -> { |
|
||||
// Reschedule WorkManager fetch |
|
||||
val config = ContentFetchConfig( |
|
||||
enabled = schedule.enabled, |
|
||||
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *", |
|
||||
url = null, // Will use mock content |
|
||||
timeout = 30000, |
|
||||
retryAttempts = 3, |
|
||||
retryDelay = 1000, |
|
||||
callbacks = CallbackConfig() |
|
||||
) |
|
||||
FetchWorker.scheduleFetch(context, config) |
|
||||
Log.i(TAG, "Rescheduled fetch for schedule: ${schedule.id}") |
|
||||
} |
|
||||
"notify" -> { |
|
||||
// Reschedule AlarmManager notification |
|
||||
val nextRunTime = calculateNextRunTime(schedule) |
|
||||
if (nextRunTime > System.currentTimeMillis()) { |
|
||||
val config = UserNotificationConfig( |
|
||||
enabled = schedule.enabled, |
|
||||
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *", |
|
||||
title = "Daily Notification", |
|
||||
body = "Your daily update is ready", |
|
||||
sound = true, |
|
||||
vibration = true, |
|
||||
priority = "normal" |
|
||||
) |
|
||||
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config) |
|
||||
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}") |
|
||||
} |
|
||||
} |
|
||||
else -> { |
|
||||
Log.w(TAG, "Unknown schedule kind: ${schedule.kind}") |
|
||||
} |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "Failed to reschedule ${schedule.kind} for ${schedule.id}", e) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Record boot recovery in history |
|
||||
try { |
|
||||
db.historyDao().insert( |
|
||||
History( |
|
||||
refId = "boot_recovery_${System.currentTimeMillis()}", |
|
||||
kind = "boot_recovery", |
|
||||
occurredAt = System.currentTimeMillis(), |
|
||||
outcome = "success", |
|
||||
diagJson = "{\"schedules_rescheduled\": ${enabledSchedules.size}}" |
|
||||
) |
|
||||
) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "Failed to record boot recovery", e) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private fun calculateNextRunTime(schedule: Schedule): Long { |
|
||||
val now = System.currentTimeMillis() |
|
||||
|
|
||||
// Simple implementation - for production, use proper cron parsing |
|
||||
return when { |
|
||||
schedule.cron != null -> { |
|
||||
// Parse cron expression and calculate next run |
|
||||
// For now, return next day at 9 AM |
|
||||
now + (24 * 60 * 60 * 1000L) |
|
||||
} |
|
||||
schedule.clockTime != null -> { |
|
||||
// Parse HH:mm and calculate next run |
|
||||
// For now, return next day at specified time |
|
||||
now + (24 * 60 * 60 * 1000L) |
|
||||
} |
|
||||
else -> { |
|
||||
// Default to next day at 9 AM |
|
||||
now + (24 * 60 * 60 * 1000L) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Data classes for configuration (simplified versions) |
|
||||
*/ |
|
||||
data class ContentFetchConfig( |
|
||||
val enabled: Boolean, |
|
||||
val schedule: String, |
|
||||
val url: String? = null, |
|
||||
val timeout: Int? = null, |
|
||||
val retryAttempts: Int? = null, |
|
||||
val retryDelay: Int? = null, |
|
||||
val callbacks: CallbackConfig |
|
||||
) |
|
||||
|
|
||||
data class UserNotificationConfig( |
|
||||
val enabled: Boolean, |
|
||||
val schedule: String, |
|
||||
val title: String? = null, |
|
||||
val body: String? = null, |
|
||||
val sound: Boolean? = null, |
|
||||
val vibration: Boolean? = null, |
|
||||
val priority: String? = null |
|
||||
) |
|
||||
|
|
||||
data class CallbackConfig( |
|
||||
val apiService: String? = null, |
|
||||
val database: String? = null, |
|
||||
val reporting: String? = null |
|
||||
) |
|
||||
@ -1,144 +0,0 @@ |
|||||
package com.timesafari.dailynotification |
|
||||
|
|
||||
import androidx.room.* |
|
||||
import androidx.room.migration.Migration |
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase |
|
||||
|
|
||||
/** |
|
||||
* SQLite schema for Daily Notification Plugin |
|
||||
* Implements TTL-at-fire invariant and rolling window armed design |
|
||||
* |
|
||||
* @author Matthew Raymer |
|
||||
* @version 1.1.0 |
|
||||
*/ |
|
||||
@Entity(tableName = "content_cache") |
|
||||
data class ContentCache( |
|
||||
@PrimaryKey val id: String, |
|
||||
val fetchedAt: Long, // epoch ms |
|
||||
val ttlSeconds: Int, |
|
||||
val payload: ByteArray, // BLOB |
|
||||
val meta: String? = null |
|
||||
) |
|
||||
|
|
||||
@Entity(tableName = "schedules") |
|
||||
data class Schedule( |
|
||||
@PrimaryKey val id: String, |
|
||||
val kind: String, // 'fetch' or 'notify' |
|
||||
val cron: String? = null, // optional cron expression |
|
||||
val clockTime: String? = null, // optional HH:mm |
|
||||
val enabled: Boolean = true, |
|
||||
val lastRunAt: Long? = null, |
|
||||
val nextRunAt: Long? = null, |
|
||||
val jitterMs: Int = 0, |
|
||||
val backoffPolicy: String = "exp", |
|
||||
val stateJson: String? = null |
|
||||
) |
|
||||
|
|
||||
@Entity(tableName = "callbacks") |
|
||||
data class Callback( |
|
||||
@PrimaryKey val id: String, |
|
||||
val kind: String, // 'http', 'local', 'queue' |
|
||||
val target: String, // url_or_local |
|
||||
val headersJson: String? = null, |
|
||||
val enabled: Boolean = true, |
|
||||
val createdAt: Long |
|
||||
) |
|
||||
|
|
||||
@Entity(tableName = "history") |
|
||||
data class History( |
|
||||
@PrimaryKey(autoGenerate = true) val id: Int = 0, |
|
||||
val refId: String, // content or schedule id |
|
||||
val kind: String, // fetch/notify/callback |
|
||||
val occurredAt: Long, |
|
||||
val durationMs: Long? = null, |
|
||||
val outcome: String, // success|failure|skipped_ttl|circuit_open |
|
||||
val diagJson: String? = null |
|
||||
) |
|
||||
|
|
||||
@Database( |
|
||||
entities = [ContentCache::class, Schedule::class, Callback::class, History::class], |
|
||||
version = 1, |
|
||||
exportSchema = false |
|
||||
) |
|
||||
@TypeConverters(Converters::class) |
|
||||
abstract class DailyNotificationDatabase : RoomDatabase() { |
|
||||
abstract fun contentCacheDao(): ContentCacheDao |
|
||||
abstract fun scheduleDao(): ScheduleDao |
|
||||
abstract fun callbackDao(): CallbackDao |
|
||||
abstract fun historyDao(): HistoryDao |
|
||||
} |
|
||||
|
|
||||
@Dao |
|
||||
interface ContentCacheDao { |
|
||||
@Query("SELECT * FROM content_cache WHERE id = :id") |
|
||||
suspend fun getById(id: String): ContentCache? |
|
||||
|
|
||||
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1") |
|
||||
suspend fun getLatest(): ContentCache? |
|
||||
|
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) |
|
||||
suspend fun upsert(contentCache: ContentCache) |
|
||||
|
|
||||
@Query("DELETE FROM content_cache WHERE fetchedAt < :cutoffTime") |
|
||||
suspend fun deleteOlderThan(cutoffTime: Long) |
|
||||
|
|
||||
@Query("SELECT COUNT(*) FROM content_cache") |
|
||||
suspend fun getCount(): Int |
|
||||
} |
|
||||
|
|
||||
@Dao |
|
||||
interface ScheduleDao { |
|
||||
@Query("SELECT * FROM schedules WHERE enabled = 1") |
|
||||
suspend fun getEnabled(): List<Schedule> |
|
||||
|
|
||||
@Query("SELECT * FROM schedules WHERE id = :id") |
|
||||
suspend fun getById(id: String): Schedule? |
|
||||
|
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) |
|
||||
suspend fun upsert(schedule: Schedule) |
|
||||
|
|
||||
@Query("UPDATE schedules SET enabled = :enabled WHERE id = :id") |
|
||||
suspend fun setEnabled(id: String, enabled: Boolean) |
|
||||
|
|
||||
@Query("UPDATE schedules SET lastRunAt = :lastRunAt, nextRunAt = :nextRunAt WHERE id = :id") |
|
||||
suspend fun updateRunTimes(id: String, lastRunAt: Long?, nextRunAt: Long?) |
|
||||
} |
|
||||
|
|
||||
@Dao |
|
||||
interface CallbackDao { |
|
||||
@Query("SELECT * FROM callbacks WHERE enabled = 1") |
|
||||
suspend fun getEnabled(): List<Callback> |
|
||||
|
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) |
|
||||
suspend fun upsert(callback: Callback) |
|
||||
|
|
||||
@Query("DELETE FROM callbacks WHERE id = :id") |
|
||||
suspend fun deleteById(id: String) |
|
||||
} |
|
||||
|
|
||||
@Dao |
|
||||
interface HistoryDao { |
|
||||
@Insert |
|
||||
suspend fun insert(history: History) |
|
||||
|
|
||||
@Query("SELECT * FROM history WHERE occurredAt >= :since ORDER BY occurredAt DESC") |
|
||||
suspend fun getSince(since: Long): List<History> |
|
||||
|
|
||||
@Query("DELETE FROM history WHERE occurredAt < :cutoffTime") |
|
||||
suspend fun deleteOlderThan(cutoffTime: Long) |
|
||||
|
|
||||
@Query("SELECT COUNT(*) FROM history") |
|
||||
suspend fun getCount(): Int |
|
||||
} |
|
||||
|
|
||||
class Converters { |
|
||||
@TypeConverter |
|
||||
fun fromByteArray(value: ByteArray?): String? { |
|
||||
return value?.let { String(it) } |
|
||||
} |
|
||||
|
|
||||
@TypeConverter |
|
||||
fun toByteArray(value: String?): ByteArray? { |
|
||||
return value?.toByteArray() |
|
||||
} |
|
||||
} |
|
||||
@ -1,202 +0,0 @@ |
|||||
package com.timesafari.dailynotification |
|
||||
|
|
||||
import android.content.Context |
|
||||
import android.util.Log |
|
||||
import androidx.work.* |
|
||||
import kotlinx.coroutines.Dispatchers |
|
||||
import kotlinx.coroutines.withContext |
|
||||
import java.io.IOException |
|
||||
import java.net.HttpURLConnection |
|
||||
import java.net.URL |
|
||||
import java.util.concurrent.TimeUnit |
|
||||
|
|
||||
/** |
|
||||
* WorkManager implementation for content fetching |
|
||||
* Implements exponential backoff and network constraints |
|
||||
* |
|
||||
* @author Matthew Raymer |
|
||||
* @version 1.1.0 |
|
||||
*/ |
|
||||
class FetchWorker( |
|
||||
appContext: Context, |
|
||||
workerParams: WorkerParameters |
|
||||
) : CoroutineWorker(appContext, workerParams) { |
|
||||
|
|
||||
companion object { |
|
||||
private const val TAG = "DNP-FETCH" |
|
||||
private const val WORK_NAME = "fetch_content" |
|
||||
|
|
||||
fun scheduleFetch(context: Context, config: ContentFetchConfig) { |
|
||||
val constraints = Constraints.Builder() |
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED) |
|
||||
.build() |
|
||||
|
|
||||
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>() |
|
||||
.setConstraints(constraints) |
|
||||
.setBackoffCriteria( |
|
||||
BackoffPolicy.EXPONENTIAL, |
|
||||
30, |
|
||||
TimeUnit.SECONDS |
|
||||
) |
|
||||
.setInputData( |
|
||||
Data.Builder() |
|
||||
.putString("url", config.url) |
|
||||
.putString("headers", config.headers?.toString()) |
|
||||
.putInt("timeout", config.timeout ?: 30000) |
|
||||
.putInt("retryAttempts", config.retryAttempts ?: 3) |
|
||||
.putInt("retryDelay", config.retryDelay ?: 1000) |
|
||||
.build() |
|
||||
) |
|
||||
.build() |
|
||||
|
|
||||
WorkManager.getInstance(context) |
|
||||
.enqueueUniqueWork( |
|
||||
WORK_NAME, |
|
||||
ExistingWorkPolicy.REPLACE, |
|
||||
workRequest |
|
||||
) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { |
|
||||
val start = SystemClock.elapsedRealtime() |
|
||||
val url = inputData.getString("url") |
|
||||
val timeout = inputData.getInt("timeout", 30000) |
|
||||
val retryAttempts = inputData.getInt("retryAttempts", 3) |
|
||||
val retryDelay = inputData.getInt("retryDelay", 1000) |
|
||||
|
|
||||
try { |
|
||||
Log.i(TAG, "Starting content fetch from: $url") |
|
||||
|
|
||||
val payload = fetchContent(url, timeout, retryAttempts, retryDelay) |
|
||||
val contentCache = ContentCache( |
|
||||
id = generateId(), |
|
||||
fetchedAt = System.currentTimeMillis(), |
|
||||
ttlSeconds = 3600, // 1 hour default TTL |
|
||||
payload = payload, |
|
||||
meta = "fetched_by_workmanager" |
|
||||
) |
|
||||
|
|
||||
// Store in database |
|
||||
val db = DailyNotificationDatabase.getDatabase(applicationContext) |
|
||||
db.contentCacheDao().upsert(contentCache) |
|
||||
|
|
||||
// Record success in history |
|
||||
db.historyDao().insert( |
|
||||
History( |
|
||||
refId = contentCache.id, |
|
||||
kind = "fetch", |
|
||||
occurredAt = System.currentTimeMillis(), |
|
||||
durationMs = SystemClock.elapsedRealtime() - start, |
|
||||
outcome = "success" |
|
||||
) |
|
||||
) |
|
||||
|
|
||||
Log.i(TAG, "Content fetch completed successfully") |
|
||||
Result.success() |
|
||||
|
|
||||
} catch (e: IOException) { |
|
||||
Log.w(TAG, "Network error during fetch", e) |
|
||||
recordFailure("network_error", start, e) |
|
||||
Result.retry() |
|
||||
|
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "Unexpected error during fetch", e) |
|
||||
recordFailure("unexpected_error", start, e) |
|
||||
Result.failure() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private suspend fun fetchContent( |
|
||||
url: String?, |
|
||||
timeout: Int, |
|
||||
retryAttempts: Int, |
|
||||
retryDelay: Int |
|
||||
): ByteArray { |
|
||||
if (url.isNullOrBlank()) { |
|
||||
// Generate mock content for testing |
|
||||
return generateMockContent() |
|
||||
} |
|
||||
|
|
||||
var lastException: Exception? = null |
|
||||
|
|
||||
repeat(retryAttempts) { attempt -> |
|
||||
try { |
|
||||
val connection = URL(url).openConnection() as HttpURLConnection |
|
||||
connection.connectTimeout = timeout |
|
||||
connection.readTimeout = timeout |
|
||||
connection.requestMethod = "GET" |
|
||||
|
|
||||
val responseCode = connection.responseCode |
|
||||
if (responseCode == HttpURLConnection.HTTP_OK) { |
|
||||
return connection.inputStream.readBytes() |
|
||||
} else { |
|
||||
throw IOException("HTTP $responseCode: ${connection.responseMessage}") |
|
||||
} |
|
||||
|
|
||||
} catch (e: Exception) { |
|
||||
lastException = e |
|
||||
if (attempt < retryAttempts - 1) { |
|
||||
Log.w(TAG, "Fetch attempt ${attempt + 1} failed, retrying in ${retryDelay}ms", e) |
|
||||
kotlinx.coroutines.delay(retryDelay.toLong()) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
throw lastException ?: IOException("All retry attempts failed") |
|
||||
} |
|
||||
|
|
||||
private fun generateMockContent(): ByteArray { |
|
||||
val mockData = """ |
|
||||
{ |
|
||||
"timestamp": ${System.currentTimeMillis()}, |
|
||||
"content": "Daily notification content", |
|
||||
"source": "mock_generator", |
|
||||
"version": "1.1.0" |
|
||||
} |
|
||||
""".trimIndent() |
|
||||
return mockData.toByteArray() |
|
||||
} |
|
||||
|
|
||||
private suspend fun recordFailure(outcome: String, start: Long, error: Throwable) { |
|
||||
try { |
|
||||
val db = DailyNotificationDatabase.getDatabase(applicationContext) |
|
||||
db.historyDao().insert( |
|
||||
History( |
|
||||
refId = "fetch_${System.currentTimeMillis()}", |
|
||||
kind = "fetch", |
|
||||
occurredAt = System.currentTimeMillis(), |
|
||||
durationMs = SystemClock.elapsedRealtime() - start, |
|
||||
outcome = outcome, |
|
||||
diagJson = "{\"error\": \"${error.message}\"}" |
|
||||
) |
|
||||
) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "Failed to record failure", e) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private fun generateId(): String { |
|
||||
return "fetch_${System.currentTimeMillis()}_${(1000..9999).random()}" |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Database singleton for Room |
|
||||
*/ |
|
||||
object DailyNotificationDatabase { |
|
||||
@Volatile |
|
||||
private var INSTANCE: DailyNotificationDatabase? = null |
|
||||
|
|
||||
fun getDatabase(context: Context): DailyNotificationDatabase { |
|
||||
return INSTANCE ?: synchronized(this) { |
|
||||
val instance = Room.databaseBuilder( |
|
||||
context.applicationContext, |
|
||||
DailyNotificationDatabase::class.java, |
|
||||
"daily_notification_database" |
|
||||
).build() |
|
||||
INSTANCE = instance |
|
||||
instance |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,336 +0,0 @@ |
|||||
package com.timesafari.dailynotification |
|
||||
|
|
||||
import android.app.AlarmManager |
|
||||
import android.app.NotificationChannel |
|
||||
import android.app.NotificationManager |
|
||||
import android.app.PendingIntent |
|
||||
import android.content.BroadcastReceiver |
|
||||
import android.content.Context |
|
||||
import android.content.Intent |
|
||||
import android.os.Build |
|
||||
import android.util.Log |
|
||||
import androidx.core.app.NotificationCompat |
|
||||
import kotlinx.coroutines.CoroutineScope |
|
||||
import kotlinx.coroutines.Dispatchers |
|
||||
import kotlinx.coroutines.launch |
|
||||
|
|
||||
/** |
|
||||
* AlarmManager implementation for user notifications |
|
||||
* Implements TTL-at-fire logic and notification delivery |
|
||||
* |
|
||||
* @author Matthew Raymer |
|
||||
* @version 1.1.0 |
|
||||
*/ |
|
||||
class NotifyReceiver : BroadcastReceiver() { |
|
||||
|
|
||||
companion object { |
|
||||
private const val TAG = "DNP-NOTIFY" |
|
||||
private const val CHANNEL_ID = "daily_notifications" |
|
||||
private const val NOTIFICATION_ID = 1001 |
|
||||
private const val REQUEST_CODE = 2001 |
|
||||
|
|
||||
fun scheduleExactNotification( |
|
||||
context: Context, |
|
||||
triggerAtMillis: Long, |
|
||||
config: UserNotificationConfig |
|
||||
) { |
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager |
|
||||
val intent = Intent(context, NotifyReceiver::class.java).apply { |
|
||||
putExtra("title", config.title) |
|
||||
putExtra("body", config.body) |
|
||||
putExtra("sound", config.sound ?: true) |
|
||||
putExtra("vibration", config.vibration ?: true) |
|
||||
putExtra("priority", config.priority ?: "normal") |
|
||||
} |
|
||||
|
|
||||
val pendingIntent = PendingIntent.getBroadcast( |
|
||||
context, |
|
||||
REQUEST_CODE, |
|
||||
intent, |
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE |
|
||||
) |
|
||||
|
|
||||
try { |
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { |
|
||||
alarmManager.setExactAndAllowWhileIdle( |
|
||||
AlarmManager.RTC_WAKEUP, |
|
||||
triggerAtMillis, |
|
||||
pendingIntent |
|
||||
) |
|
||||
} else { |
|
||||
alarmManager.setExact( |
|
||||
AlarmManager.RTC_WAKEUP, |
|
||||
triggerAtMillis, |
|
||||
pendingIntent |
|
||||
) |
|
||||
} |
|
||||
Log.i(TAG, "Exact notification scheduled for: $triggerAtMillis") |
|
||||
} catch (e: SecurityException) { |
|
||||
Log.w(TAG, "Cannot schedule exact alarm, falling back to inexact", e) |
|
||||
alarmManager.set( |
|
||||
AlarmManager.RTC_WAKEUP, |
|
||||
triggerAtMillis, |
|
||||
pendingIntent |
|
||||
) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
fun cancelNotification(context: Context) { |
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager |
|
||||
val intent = Intent(context, NotifyReceiver::class.java) |
|
||||
val pendingIntent = PendingIntent.getBroadcast( |
|
||||
context, |
|
||||
REQUEST_CODE, |
|
||||
intent, |
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE |
|
||||
) |
|
||||
alarmManager.cancel(pendingIntent) |
|
||||
Log.i(TAG, "Notification alarm cancelled") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
override fun onReceive(context: Context, intent: Intent?) { |
|
||||
Log.i(TAG, "Notification receiver triggered") |
|
||||
|
|
||||
CoroutineScope(Dispatchers.IO).launch { |
|
||||
try { |
|
||||
// Check if this is a static reminder (no content dependency) |
|
||||
val isStaticReminder = intent?.getBooleanExtra("is_static_reminder", false) ?: false |
|
||||
|
|
||||
if (isStaticReminder) { |
|
||||
// Handle static reminder without content cache |
|
||||
val title = intent?.getStringExtra("title") ?: "Daily Reminder" |
|
||||
val body = intent?.getStringExtra("body") ?: "Don't forget your daily check-in!" |
|
||||
val sound = intent?.getBooleanExtra("sound", true) ?: true |
|
||||
val vibration = intent?.getBooleanExtra("vibration", true) ?: true |
|
||||
val priority = intent?.getStringExtra("priority") ?: "normal" |
|
||||
val reminderId = intent?.getStringExtra("reminder_id") ?: "unknown" |
|
||||
|
|
||||
showStaticReminderNotification(context, title, body, sound, vibration, priority, reminderId) |
|
||||
|
|
||||
// Record reminder trigger in database |
|
||||
recordReminderTrigger(context, reminderId) |
|
||||
return@launch |
|
||||
} |
|
||||
|
|
||||
// Existing cached content logic for regular notifications |
|
||||
val db = DailyNotificationDatabase.getDatabase(context) |
|
||||
val latestCache = db.contentCacheDao().getLatest() |
|
||||
|
|
||||
if (latestCache == null) { |
|
||||
Log.w(TAG, "No cached content available for notification") |
|
||||
recordHistory(db, "notify", "no_content") |
|
||||
return@launch |
|
||||
} |
|
||||
|
|
||||
// TTL-at-fire check |
|
||||
val now = System.currentTimeMillis() |
|
||||
val ttlExpiry = latestCache.fetchedAt + (latestCache.ttlSeconds * 1000L) |
|
||||
|
|
||||
if (now > ttlExpiry) { |
|
||||
Log.i(TAG, "Content TTL expired, skipping notification") |
|
||||
recordHistory(db, "notify", "skipped_ttl") |
|
||||
return@launch |
|
||||
} |
|
||||
|
|
||||
// Show notification |
|
||||
val title = intent?.getStringExtra("title") ?: "Daily Notification" |
|
||||
val body = intent?.getStringExtra("body") ?: String(latestCache.payload) |
|
||||
val sound = intent?.getBooleanExtra("sound", true) ?: true |
|
||||
val vibration = intent?.getBooleanExtra("vibration", true) ?: true |
|
||||
val priority = intent?.getStringExtra("priority") ?: "normal" |
|
||||
|
|
||||
showNotification(context, title, body, sound, vibration, priority) |
|
||||
recordHistory(db, "notify", "success") |
|
||||
|
|
||||
// Fire callbacks |
|
||||
fireCallbacks(context, db, "onNotifyDelivered", latestCache) |
|
||||
|
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "Error in notification receiver", e) |
|
||||
try { |
|
||||
val db = DailyNotificationDatabase.getDatabase(context) |
|
||||
recordHistory(db, "notify", "failure", e.message) |
|
||||
} catch (dbError: Exception) { |
|
||||
Log.e(TAG, "Failed to record notification failure", dbError) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private fun showNotification( |
|
||||
context: Context, |
|
||||
title: String, |
|
||||
body: String, |
|
||||
sound: Boolean, |
|
||||
vibration: Boolean, |
|
||||
priority: String |
|
||||
) { |
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager |
|
||||
|
|
||||
// Create notification channel for Android 8.0+ |
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|
||||
val channel = NotificationChannel( |
|
||||
CHANNEL_ID, |
|
||||
"Daily Notifications", |
|
||||
when (priority) { |
|
||||
"high" -> NotificationManager.IMPORTANCE_HIGH |
|
||||
"low" -> NotificationManager.IMPORTANCE_LOW |
|
||||
else -> NotificationManager.IMPORTANCE_DEFAULT |
|
||||
} |
|
||||
).apply { |
|
||||
enableVibration(vibration) |
|
||||
if (!sound) { |
|
||||
setSound(null, null) |
|
||||
} |
|
||||
} |
|
||||
notificationManager.createNotificationChannel(channel) |
|
||||
} |
|
||||
|
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID) |
|
||||
.setContentTitle(title) |
|
||||
.setContentText(body) |
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info) |
|
||||
.setPriority( |
|
||||
when (priority) { |
|
||||
"high" -> NotificationCompat.PRIORITY_HIGH |
|
||||
"low" -> NotificationCompat.PRIORITY_LOW |
|
||||
else -> NotificationCompat.PRIORITY_DEFAULT |
|
||||
} |
|
||||
) |
|
||||
.setAutoCancel(true) |
|
||||
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null) |
|
||||
.build() |
|
||||
|
|
||||
notificationManager.notify(NOTIFICATION_ID, notification) |
|
||||
Log.i(TAG, "Notification displayed: $title") |
|
||||
} |
|
||||
|
|
||||
private suspend fun recordHistory( |
|
||||
db: DailyNotificationDatabase, |
|
||||
kind: String, |
|
||||
outcome: String, |
|
||||
diagJson: String? = null |
|
||||
) { |
|
||||
try { |
|
||||
db.historyDao().insert( |
|
||||
History( |
|
||||
refId = "notify_${System.currentTimeMillis()}", |
|
||||
kind = kind, |
|
||||
occurredAt = System.currentTimeMillis(), |
|
||||
outcome = outcome, |
|
||||
diagJson = diagJson |
|
||||
) |
|
||||
) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "Failed to record history", e) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private suspend fun fireCallbacks( |
|
||||
context: Context, |
|
||||
db: DailyNotificationDatabase, |
|
||||
eventType: String, |
|
||||
contentCache: ContentCache |
|
||||
) { |
|
||||
try { |
|
||||
val callbacks = db.callbackDao().getEnabled() |
|
||||
callbacks.forEach { callback -> |
|
||||
try { |
|
||||
when (callback.kind) { |
|
||||
"http" -> fireHttpCallback(callback, eventType, contentCache) |
|
||||
"local" -> fireLocalCallback(context, callback, eventType, contentCache) |
|
||||
else -> Log.w(TAG, "Unknown callback kind: ${callback.kind}") |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "Failed to fire callback ${callback.id}", e) |
|
||||
recordHistory(db, "callback", "failure", "{\"callback_id\": \"${callback.id}\", \"error\": \"${e.message}\"}") |
|
||||
} |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "Failed to fire callbacks", e) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private suspend fun fireHttpCallback( |
|
||||
callback: Callback, |
|
||||
eventType: String, |
|
||||
contentCache: ContentCache |
|
||||
) { |
|
||||
// HTTP callback implementation would go here |
|
||||
Log.i(TAG, "HTTP callback fired: ${callback.id} for event: $eventType") |
|
||||
} |
|
||||
|
|
||||
private suspend fun fireLocalCallback( |
|
||||
context: Context, |
|
||||
callback: Callback, |
|
||||
eventType: String, |
|
||||
contentCache: ContentCache |
|
||||
) { |
|
||||
// Local callback implementation would go here |
|
||||
Log.i(TAG, "Local callback fired: ${callback.id} for event: $eventType") |
|
||||
} |
|
||||
|
|
||||
// Static Reminder Helper Methods |
|
||||
private fun showStaticReminderNotification( |
|
||||
context: Context, |
|
||||
title: String, |
|
||||
body: String, |
|
||||
sound: Boolean, |
|
||||
vibration: Boolean, |
|
||||
priority: String, |
|
||||
reminderId: String |
|
||||
) { |
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager |
|
||||
|
|
||||
// Create notification channel for reminders |
|
||||
createReminderNotificationChannel(context, notificationManager) |
|
||||
|
|
||||
val notification = NotificationCompat.Builder(context, "daily_reminders") |
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info) |
|
||||
.setContentTitle(title) |
|
||||
.setContentText(body) |
|
||||
.setPriority( |
|
||||
when (priority) { |
|
||||
"high" -> NotificationCompat.PRIORITY_HIGH |
|
||||
"low" -> NotificationCompat.PRIORITY_LOW |
|
||||
else -> NotificationCompat.PRIORITY_DEFAULT |
|
||||
} |
|
||||
) |
|
||||
.setSound(if (sound) null else null) // Use default sound if enabled |
|
||||
.setAutoCancel(true) |
|
||||
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null) |
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER) |
|
||||
.build() |
|
||||
|
|
||||
notificationManager.notify(reminderId.hashCode(), notification) |
|
||||
Log.i(TAG, "Static reminder displayed: $title (ID: $reminderId)") |
|
||||
} |
|
||||
|
|
||||
private fun createReminderNotificationChannel(context: Context, notificationManager: NotificationManager) { |
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|
||||
val channel = NotificationChannel( |
|
||||
"daily_reminders", |
|
||||
"Daily Reminders", |
|
||||
NotificationManager.IMPORTANCE_DEFAULT |
|
||||
).apply { |
|
||||
description = "Daily reminder notifications" |
|
||||
enableVibration(true) |
|
||||
setShowBadge(true) |
|
||||
} |
|
||||
notificationManager.createNotificationChannel(channel) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private fun recordReminderTrigger(context: Context, reminderId: String) { |
|
||||
try { |
|
||||
val prefs = context.getSharedPreferences("daily_reminders", Context.MODE_PRIVATE) |
|
||||
val editor = prefs.edit() |
|
||||
editor.putLong("${reminderId}_lastTriggered", System.currentTimeMillis()) |
|
||||
editor.apply() |
|
||||
Log.d(TAG, "Reminder trigger recorded: $reminderId") |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "Error recording reminder trigger", e) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,29 +1,102 @@ |
|||||
// Top-level build file where you can add configuration options common to all sub-projects/modules. |
apply plugin: 'com.android.library' |
||||
|
|
||||
buildscript { |
buildscript { |
||||
|
|
||||
repositories { |
repositories { |
||||
google() |
google() |
||||
mavenCentral() |
mavenCentral() |
||||
} |
} |
||||
dependencies { |
dependencies { |
||||
classpath 'com.android.tools.build:gradle:8.13.0' |
classpath 'com.android.tools.build:gradle:8.13.0' |
||||
classpath 'com.google.gms:google-services:4.4.0' |
} |
||||
|
} |
||||
|
|
||||
|
android { |
||||
|
namespace "com.timesafari.dailynotification.plugin" |
||||
|
compileSdk 35 |
||||
|
|
||||
|
defaultConfig { |
||||
|
minSdk 23 |
||||
|
targetSdk 35 |
||||
|
|
||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" |
||||
|
consumerProguardFiles "consumer-rules.pro" |
||||
|
} |
||||
|
|
||||
// NOTE: Do not place your application dependencies here; they belong |
buildTypes { |
||||
// in the individual module build.gradle files |
release { |
||||
|
minifyEnabled false |
||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' |
||||
} |
} |
||||
|
debug { |
||||
|
minifyEnabled false |
||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
compileOptions { |
||||
|
sourceCompatibility JavaVersion.VERSION_1_8 |
||||
|
targetCompatibility JavaVersion.VERSION_1_8 |
||||
} |
} |
||||
|
|
||||
apply from: "variables.gradle" |
// Disable test compilation - tests reference deprecated/removed code |
||||
|
// TODO: Rewrite tests to use modern AndroidX testing framework |
||||
|
testOptions { |
||||
|
unitTests.all { |
||||
|
enabled = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Exclude test sources from compilation |
||||
|
sourceSets { |
||||
|
test { |
||||
|
java { |
||||
|
srcDirs = [] // Disable test source compilation |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
allprojects { |
|
||||
repositories { |
repositories { |
||||
google() |
google() |
||||
mavenCentral() |
mavenCentral() |
||||
|
|
||||
|
// Try to find Capacitor from node_modules (for standalone builds) |
||||
|
// In consuming apps, Capacitor will be available as a project dependency |
||||
|
def capacitorPath = new File(rootProject.projectDir, '../node_modules/@capacitor/android/capacitor') |
||||
|
if (capacitorPath.exists()) { |
||||
|
flatDir { |
||||
|
dirs capacitorPath |
||||
|
} |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
task clean(type: Delete) { |
dependencies { |
||||
delete rootProject.buildDir |
// Capacitor dependency - provided by consuming app |
||||
|
// When included as a project dependency, use project reference |
||||
|
// When building standalone, this will fail (expected - plugin must be built within a Capacitor app) |
||||
|
def capacitorProject = project.findProject(':capacitor-android') |
||||
|
if (capacitorProject != null) { |
||||
|
implementation capacitorProject |
||||
|
} else { |
||||
|
// Try to find from node_modules (for syntax checking only) |
||||
|
def capacitorPath = new File(rootProject.projectDir, '../node_modules/@capacitor/android/capacitor') |
||||
|
if (capacitorPath.exists() && new File(capacitorPath, 'build.gradle').exists()) { |
||||
|
// If we're in a Capacitor app context, try to include it |
||||
|
throw new GradleException("Capacitor Android project not found. This plugin must be built within a Capacitor app that includes :capacitor-android.") |
||||
|
} else { |
||||
|
throw new GradleException("Capacitor Android not found. This plugin must be built within a Capacitor app context.") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// These dependencies are always available from Maven |
||||
|
implementation "androidx.appcompat:appcompat:1.7.0" |
||||
|
implementation "androidx.room:room-runtime:2.6.1" |
||||
|
implementation "androidx.work:work-runtime-ktx:2.9.0" |
||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" |
||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.10" |
||||
|
implementation "com.google.code.gson:gson:2.10.1" |
||||
|
implementation "androidx.core:core:1.12.0" |
||||
|
|
||||
|
annotationProcessor "androidx.room:room-compiler:2.6.1" |
||||
} |
} |
||||
|
|
||||
|
|||||
@ -1,3 +0,0 @@ |
|||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN |
|
||||
include ':capacitor-android' |
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') |
|
||||
@ -0,0 +1,10 @@ |
|||||
|
# Consumer ProGuard rules for Daily Notification Plugin |
||||
|
# These rules are applied to consuming apps when they use this plugin |
||||
|
|
||||
|
# Keep plugin classes |
||||
|
-keep class com.timesafari.dailynotification.** { *; } |
||||
|
|
||||
|
# Keep Capacitor plugin interface |
||||
|
-keep class com.getcapacitor.Plugin { *; } |
||||
|
-keep @com.getcapacitor.Plugin class * { *; } |
||||
|
|
||||
@ -1,67 +0,0 @@ |
|||||
apply plugin: 'com.android.library' |
|
||||
|
|
||||
android { |
|
||||
namespace "com.timesafari.dailynotification.plugin" |
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion |
|
||||
|
|
||||
defaultConfig { |
|
||||
minSdkVersion rootProject.ext.minSdkVersion |
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion |
|
||||
|
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" |
|
||||
consumerProguardFiles "consumer-rules.pro" |
|
||||
} |
|
||||
|
|
||||
buildTypes { |
|
||||
release { |
|
||||
minifyEnabled false |
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' |
|
||||
} |
|
||||
debug { |
|
||||
minifyEnabled false |
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
compileOptions { |
|
||||
sourceCompatibility JavaVersion.VERSION_1_8 |
|
||||
targetCompatibility JavaVersion.VERSION_1_8 |
|
||||
} |
|
||||
|
|
||||
// Disable test compilation - tests reference deprecated/removed code |
|
||||
// TODO: Rewrite tests to use modern AndroidX testing framework |
|
||||
testOptions { |
|
||||
unitTests.all { |
|
||||
enabled = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Exclude test sources from compilation |
|
||||
sourceSets { |
|
||||
test { |
|
||||
java { |
|
||||
srcDirs = [] // Disable test source compilation |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
dependencies { |
|
||||
implementation project(':capacitor-android') |
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" |
|
||||
implementation "androidx.room:room-runtime:2.6.1" |
|
||||
implementation "androidx.work:work-runtime-ktx:2.9.0" |
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" |
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.10" |
|
||||
implementation "com.google.code.gson:gson:2.10.1" |
|
||||
implementation "androidx.core:core:1.12.0" |
|
||||
|
|
||||
annotationProcessor "androidx.room:room-compiler:2.6.1" |
|
||||
annotationProcessor project(':capacitor-android') |
|
||||
|
|
||||
// Temporarily disabled tests due to deprecated Android testing APIs |
|
||||
// TODO: Update test files to use modern AndroidX testing framework |
|
||||
// testImplementation "junit:junit:$junitVersion" |
|
||||
// androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" |
|
||||
// androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" |
|
||||
} |
|
||||
@ -1,215 +0,0 @@ |
|||||
/** |
|
||||
* DailyNotificationDatabaseTest.java |
|
||||
* |
|
||||
* Unit tests for SQLite database functionality |
|
||||
* Tests schema creation, WAL mode, and basic operations |
|
||||
* |
|
||||
* @author Matthew Raymer |
|
||||
* @version 1.0.0 |
|
||||
*/ |
|
||||
|
|
||||
package com.timesafari.dailynotification; |
|
||||
|
|
||||
import android.content.Context; |
|
||||
import android.database.sqlite.SQLiteDatabase; |
|
||||
import android.test.AndroidTestCase; |
|
||||
import android.test.mock.MockContext; |
|
||||
|
|
||||
import java.io.File; |
|
||||
|
|
||||
/** |
|
||||
* Unit tests for DailyNotificationDatabase |
|
||||
* |
|
||||
* Tests the core SQLite functionality including: |
|
||||
* - Database creation and schema |
|
||||
* - WAL mode configuration |
|
||||
* - Table and index creation |
|
||||
* - Schema version management |
|
||||
*/ |
|
||||
public class DailyNotificationDatabaseTest extends AndroidTestCase { |
|
||||
|
|
||||
private DailyNotificationDatabase database; |
|
||||
private Context mockContext; |
|
||||
|
|
||||
@Override |
|
||||
protected void setUp() throws Exception { |
|
||||
super.setUp(); |
|
||||
|
|
||||
// Create mock context
|
|
||||
mockContext = new MockContext() { |
|
||||
@Override |
|
||||
public File getDatabasePath(String name) { |
|
||||
return new File(getContext().getCacheDir(), name); |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
// Create database instance
|
|
||||
database = new DailyNotificationDatabase(mockContext); |
|
||||
} |
|
||||
|
|
||||
@Override |
|
||||
protected void tearDown() throws Exception { |
|
||||
if (database != null) { |
|
||||
database.close(); |
|
||||
} |
|
||||
super.tearDown(); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test database creation and schema |
|
||||
*/ |
|
||||
public void testDatabaseCreation() { |
|
||||
assertNotNull("Database should not be null", database); |
|
||||
|
|
||||
SQLiteDatabase db = database.getReadableDatabase(); |
|
||||
assertNotNull("Readable database should not be null", db); |
|
||||
assertTrue("Database should be open", db.isOpen()); |
|
||||
|
|
||||
db.close(); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test WAL mode configuration |
|
||||
*/ |
|
||||
public void testWALModeConfiguration() { |
|
||||
SQLiteDatabase db = database.getWritableDatabase(); |
|
||||
|
|
||||
// Check journal mode
|
|
||||
android.database.Cursor cursor = db.rawQuery("PRAGMA journal_mode", null); |
|
||||
assertTrue("Should have journal mode result", cursor.moveToFirst()); |
|
||||
String journalMode = cursor.getString(0); |
|
||||
assertEquals("Journal mode should be WAL", "wal", journalMode.toLowerCase()); |
|
||||
cursor.close(); |
|
||||
|
|
||||
// Check synchronous mode
|
|
||||
cursor = db.rawQuery("PRAGMA synchronous", null); |
|
||||
assertTrue("Should have synchronous result", cursor.moveToFirst()); |
|
||||
int synchronous = cursor.getInt(0); |
|
||||
assertEquals("Synchronous mode should be NORMAL", 1, synchronous); |
|
||||
cursor.close(); |
|
||||
|
|
||||
// Check foreign keys
|
|
||||
cursor = db.rawQuery("PRAGMA foreign_keys", null); |
|
||||
assertTrue("Should have foreign_keys result", cursor.moveToFirst()); |
|
||||
int foreignKeys = cursor.getInt(0); |
|
||||
assertEquals("Foreign keys should be enabled", 1, foreignKeys); |
|
||||
cursor.close(); |
|
||||
|
|
||||
db.close(); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test table creation |
|
||||
*/ |
|
||||
public void testTableCreation() { |
|
||||
SQLiteDatabase db = database.getWritableDatabase(); |
|
||||
|
|
||||
// Check if tables exist
|
|
||||
assertTrue("notif_contents table should exist", |
|
||||
tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_CONTENTS)); |
|
||||
assertTrue("notif_deliveries table should exist", |
|
||||
tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_DELIVERIES)); |
|
||||
assertTrue("notif_config table should exist", |
|
||||
tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_CONFIG)); |
|
||||
|
|
||||
db.close(); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test index creation |
|
||||
*/ |
|
||||
public void testIndexCreation() { |
|
||||
SQLiteDatabase db = database.getWritableDatabase(); |
|
||||
|
|
||||
// Check if indexes exist
|
|
||||
assertTrue("notif_idx_contents_slot_time index should exist", |
|
||||
indexExists(db, "notif_idx_contents_slot_time")); |
|
||||
assertTrue("notif_idx_deliveries_slot index should exist", |
|
||||
indexExists(db, "notif_idx_deliveries_slot")); |
|
||||
|
|
||||
db.close(); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test schema version management |
|
||||
*/ |
|
||||
public void testSchemaVersion() { |
|
||||
SQLiteDatabase db = database.getWritableDatabase(); |
|
||||
|
|
||||
// Check user_version
|
|
||||
android.database.Cursor cursor = db.rawQuery("PRAGMA user_version", null); |
|
||||
assertTrue("Should have user_version result", cursor.moveToFirst()); |
|
||||
int userVersion = cursor.getInt(0); |
|
||||
assertEquals("User version should match database version", |
|
||||
DailyNotificationDatabase.DATABASE_VERSION, userVersion); |
|
||||
cursor.close(); |
|
||||
|
|
||||
db.close(); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test basic insert operations |
|
||||
*/ |
|
||||
public void testBasicInsertOperations() { |
|
||||
SQLiteDatabase db = database.getWritableDatabase(); |
|
||||
|
|
||||
// Test inserting into notif_contents
|
|
||||
android.content.ContentValues values = new android.content.ContentValues(); |
|
||||
values.put(DailyNotificationDatabase.COL_CONTENTS_SLOT_ID, "test_slot_1"); |
|
||||
values.put(DailyNotificationDatabase.COL_CONTENTS_PAYLOAD_JSON, "{\"title\":\"Test\"}"); |
|
||||
values.put(DailyNotificationDatabase.COL_CONTENTS_FETCHED_AT, System.currentTimeMillis()); |
|
||||
|
|
||||
long rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONTENTS, null, values); |
|
||||
assertTrue("Insert should succeed", rowId > 0); |
|
||||
|
|
||||
// Test inserting into notif_config
|
|
||||
values.clear(); |
|
||||
values.put(DailyNotificationDatabase.COL_CONFIG_K, "test_key"); |
|
||||
values.put(DailyNotificationDatabase.COL_CONFIG_V, "test_value"); |
|
||||
|
|
||||
rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONFIG, null, values); |
|
||||
assertTrue("Config insert should succeed", rowId > 0); |
|
||||
|
|
||||
db.close(); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test database file operations |
|
||||
*/ |
|
||||
public void testDatabaseFileOperations() { |
|
||||
String dbPath = database.getDatabasePath(); |
|
||||
assertNotNull("Database path should not be null", dbPath); |
|
||||
assertTrue("Database path should not be empty", !dbPath.isEmpty()); |
|
||||
|
|
||||
// Database should exist after creation
|
|
||||
assertTrue("Database file should exist", database.databaseExists()); |
|
||||
|
|
||||
// Database size should be greater than 0
|
|
||||
long size = database.getDatabaseSize(); |
|
||||
assertTrue("Database size should be greater than 0", size > 0); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Helper method to check if table exists |
|
||||
*/ |
|
||||
private boolean tableExists(SQLiteDatabase db, String tableName) { |
|
||||
android.database.Cursor cursor = db.rawQuery( |
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", |
|
||||
new String[]{tableName}); |
|
||||
boolean exists = cursor.moveToFirst(); |
|
||||
cursor.close(); |
|
||||
return exists; |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Helper method to check if index exists |
|
||||
*/ |
|
||||
private boolean indexExists(SQLiteDatabase db, String indexName) { |
|
||||
android.database.Cursor cursor = db.rawQuery( |
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name=?", |
|
||||
new String[]{indexName}); |
|
||||
boolean exists = cursor.moveToFirst(); |
|
||||
cursor.close(); |
|
||||
return exists; |
|
||||
} |
|
||||
} |
|
||||
@ -1,193 +0,0 @@ |
|||||
/** |
|
||||
* DailyNotificationRollingWindowTest.java |
|
||||
* |
|
||||
* Unit tests for rolling window safety functionality |
|
||||
* Tests window maintenance, capacity management, and platform-specific limits |
|
||||
* |
|
||||
* @author Matthew Raymer |
|
||||
* @version 1.0.0 |
|
||||
*/ |
|
||||
|
|
||||
package com.timesafari.dailynotification; |
|
||||
|
|
||||
import android.content.Context; |
|
||||
import android.test.AndroidTestCase; |
|
||||
import android.test.mock.MockContext; |
|
||||
|
|
||||
import java.util.concurrent.TimeUnit; |
|
||||
|
|
||||
/** |
|
||||
* Unit tests for DailyNotificationRollingWindow |
|
||||
* |
|
||||
* Tests the rolling window safety functionality including: |
|
||||
* - Window maintenance and state updates |
|
||||
* - Capacity limit enforcement |
|
||||
* - Platform-specific behavior (iOS vs Android) |
|
||||
* - Statistics and maintenance timing |
|
||||
*/ |
|
||||
public class DailyNotificationRollingWindowTest extends AndroidTestCase { |
|
||||
|
|
||||
private DailyNotificationRollingWindow rollingWindow; |
|
||||
private Context mockContext; |
|
||||
private DailyNotificationScheduler mockScheduler; |
|
||||
private DailyNotificationTTLEnforcer mockTTLEnforcer; |
|
||||
private DailyNotificationStorage mockStorage; |
|
||||
|
|
||||
@Override |
|
||||
protected void setUp() throws Exception { |
|
||||
super.setUp(); |
|
||||
|
|
||||
// Create mock context
|
|
||||
mockContext = new MockContext() { |
|
||||
@Override |
|
||||
public android.content.SharedPreferences getSharedPreferences(String name, int mode) { |
|
||||
return getContext().getSharedPreferences(name, mode); |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
// Create mock components
|
|
||||
mockScheduler = new MockDailyNotificationScheduler(); |
|
||||
mockTTLEnforcer = new MockDailyNotificationTTLEnforcer(); |
|
||||
mockStorage = new MockDailyNotificationStorage(); |
|
||||
|
|
||||
// Create rolling window for Android platform
|
|
||||
rollingWindow = new DailyNotificationRollingWindow( |
|
||||
mockContext, |
|
||||
mockScheduler, |
|
||||
mockTTLEnforcer, |
|
||||
mockStorage, |
|
||||
false // Android platform
|
|
||||
); |
|
||||
} |
|
||||
|
|
||||
@Override |
|
||||
protected void tearDown() throws Exception { |
|
||||
super.tearDown(); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test rolling window initialization |
|
||||
*/ |
|
||||
public void testRollingWindowInitialization() { |
|
||||
assertNotNull("Rolling window should be initialized", rollingWindow); |
|
||||
|
|
||||
// Test Android platform limits
|
|
||||
String stats = rollingWindow.getRollingWindowStats(); |
|
||||
assertNotNull("Stats should not be null", stats); |
|
||||
assertTrue("Stats should contain Android platform info", stats.contains("Android")); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test rolling window maintenance |
|
||||
*/ |
|
||||
public void testRollingWindowMaintenance() { |
|
||||
// Test that maintenance can be forced
|
|
||||
rollingWindow.forceMaintenance(); |
|
||||
|
|
||||
// Test maintenance timing
|
|
||||
assertFalse("Maintenance should not be needed immediately after forcing", |
|
||||
rollingWindow.isMaintenanceNeeded()); |
|
||||
|
|
||||
// Test time until next maintenance
|
|
||||
long timeUntilNext = rollingWindow.getTimeUntilNextMaintenance(); |
|
||||
assertTrue("Time until next maintenance should be positive", timeUntilNext > 0); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test iOS platform behavior |
|
||||
*/ |
|
||||
public void testIOSPlatformBehavior() { |
|
||||
// Create rolling window for iOS platform
|
|
||||
DailyNotificationRollingWindow iosRollingWindow = new DailyNotificationRollingWindow( |
|
||||
mockContext, |
|
||||
mockScheduler, |
|
||||
mockTTLEnforcer, |
|
||||
mockStorage, |
|
||||
true // iOS platform
|
|
||||
); |
|
||||
|
|
||||
String stats = iosRollingWindow.getRollingWindowStats(); |
|
||||
assertNotNull("iOS stats should not be null", stats); |
|
||||
assertTrue("Stats should contain iOS platform info", stats.contains("iOS")); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test maintenance timing |
|
||||
*/ |
|
||||
public void testMaintenanceTiming() { |
|
||||
// Initially, maintenance should not be needed
|
|
||||
assertFalse("Maintenance should not be needed initially", |
|
||||
rollingWindow.isMaintenanceNeeded()); |
|
||||
|
|
||||
// Force maintenance
|
|
||||
rollingWindow.forceMaintenance(); |
|
||||
|
|
||||
// Should not be needed immediately after
|
|
||||
assertFalse("Maintenance should not be needed after forcing", |
|
||||
rollingWindow.isMaintenanceNeeded()); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test statistics retrieval |
|
||||
*/ |
|
||||
public void testStatisticsRetrieval() { |
|
||||
String stats = rollingWindow.getRollingWindowStats(); |
|
||||
|
|
||||
assertNotNull("Statistics should not be null", stats); |
|
||||
assertTrue("Statistics should contain pending count", stats.contains("pending")); |
|
||||
assertTrue("Statistics should contain daily count", stats.contains("daily")); |
|
||||
assertTrue("Statistics should contain platform info", stats.contains("platform")); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test error handling |
|
||||
*/ |
|
||||
public void testErrorHandling() { |
|
||||
// Test with null components (should not crash)
|
|
||||
try { |
|
||||
DailyNotificationRollingWindow errorWindow = new DailyNotificationRollingWindow( |
|
||||
null, null, null, null, false |
|
||||
); |
|
||||
// Should not crash during construction
|
|
||||
} catch (Exception e) { |
|
||||
// Expected to handle gracefully
|
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Mock DailyNotificationScheduler for testing |
|
||||
*/ |
|
||||
private static class MockDailyNotificationScheduler extends DailyNotificationScheduler { |
|
||||
public MockDailyNotificationScheduler() { |
|
||||
super(null, null); |
|
||||
} |
|
||||
|
|
||||
@Override |
|
||||
public boolean scheduleNotification(NotificationContent content) { |
|
||||
return true; // Always succeed for testing
|
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Mock DailyNotificationTTLEnforcer for testing |
|
||||
*/ |
|
||||
private static class MockDailyNotificationTTLEnforcer extends DailyNotificationTTLEnforcer { |
|
||||
public MockDailyNotificationTTLEnforcer() { |
|
||||
super(null, null, false); |
|
||||
} |
|
||||
|
|
||||
@Override |
|
||||
public boolean validateBeforeArming(NotificationContent content) { |
|
||||
return true; // Always pass validation for testing
|
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Mock DailyNotificationStorage for testing |
|
||||
*/ |
|
||||
private static class MockDailyNotificationStorage extends DailyNotificationStorage { |
|
||||
public MockDailyNotificationStorage() { |
|
||||
super(null); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,217 +0,0 @@ |
|||||
/** |
|
||||
* DailyNotificationTTLEnforcerTest.java |
|
||||
* |
|
||||
* Unit tests for TTL-at-fire enforcement functionality |
|
||||
* Tests freshness validation, TTL violation logging, and skip logic |
|
||||
* |
|
||||
* @author Matthew Raymer |
|
||||
* @version 1.0.0 |
|
||||
*/ |
|
||||
|
|
||||
package com.timesafari.dailynotification; |
|
||||
|
|
||||
import android.content.Context; |
|
||||
import android.test.AndroidTestCase; |
|
||||
import android.test.mock.MockContext; |
|
||||
|
|
||||
import java.util.concurrent.TimeUnit; |
|
||||
|
|
||||
/** |
|
||||
* Unit tests for DailyNotificationTTLEnforcer |
|
||||
* |
|
||||
* Tests the core TTL enforcement functionality including: |
|
||||
* - Freshness validation before arming |
|
||||
* - TTL violation detection and logging |
|
||||
* - Skip logic for stale content |
|
||||
* - Configuration retrieval from storage |
|
||||
*/ |
|
||||
public class DailyNotificationTTLEnforcerTest extends AndroidTestCase { |
|
||||
|
|
||||
private DailyNotificationTTLEnforcer ttlEnforcer; |
|
||||
private Context mockContext; |
|
||||
private DailyNotificationDatabase database; |
|
||||
|
|
||||
@Override |
|
||||
protected void setUp() throws Exception { |
|
||||
super.setUp(); |
|
||||
|
|
||||
// Create mock context
|
|
||||
mockContext = new MockContext() { |
|
||||
@Override |
|
||||
public android.content.SharedPreferences getSharedPreferences(String name, int mode) { |
|
||||
return getContext().getSharedPreferences(name, mode); |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
// Create database instance
|
|
||||
database = new DailyNotificationDatabase(mockContext); |
|
||||
|
|
||||
// Create TTL enforcer with SQLite storage
|
|
||||
ttlEnforcer = new DailyNotificationTTLEnforcer(mockContext, database, true); |
|
||||
} |
|
||||
|
|
||||
@Override |
|
||||
protected void tearDown() throws Exception { |
|
||||
if (database != null) { |
|
||||
database.close(); |
|
||||
} |
|
||||
super.tearDown(); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test freshness validation with fresh content |
|
||||
*/ |
|
||||
public void testFreshContentValidation() { |
|
||||
long currentTime = System.currentTimeMillis(); |
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); // 30 minutes from now
|
|
||||
long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(5); // 5 minutes ago
|
|
||||
|
|
||||
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_1", scheduledTime, fetchedAt); |
|
||||
|
|
||||
assertTrue("Content should be fresh (5 min old, scheduled 30 min from now)", isFresh); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test freshness validation with stale content |
|
||||
*/ |
|
||||
public void testStaleContentValidation() { |
|
||||
long currentTime = System.currentTimeMillis(); |
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); // 30 minutes from now
|
|
||||
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); // 2 hours ago
|
|
||||
|
|
||||
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_2", scheduledTime, fetchedAt); |
|
||||
|
|
||||
assertFalse("Content should be stale (2 hours old, exceeds 1 hour TTL)", isFresh); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test TTL violation detection |
|
||||
*/ |
|
||||
public void testTTLViolationDetection() { |
|
||||
long currentTime = System.currentTimeMillis(); |
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); |
|
||||
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); // 2 hours ago
|
|
||||
|
|
||||
// This should trigger a TTL violation
|
|
||||
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_3", scheduledTime, fetchedAt); |
|
||||
|
|
||||
assertFalse("Should detect TTL violation", isFresh); |
|
||||
|
|
||||
// Check that violation was logged (we can't easily test the actual logging,
|
|
||||
// but we can verify the method returns false as expected)
|
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test validateBeforeArming with fresh content |
|
||||
*/ |
|
||||
public void testValidateBeforeArmingFresh() { |
|
||||
long currentTime = System.currentTimeMillis(); |
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); |
|
||||
long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(5); |
|
||||
|
|
||||
NotificationContent content = new NotificationContent(); |
|
||||
content.setId("test_slot_4"); |
|
||||
content.setScheduledTime(scheduledTime); |
|
||||
content.setFetchedAt(fetchedAt); |
|
||||
content.setTitle("Test Notification"); |
|
||||
content.setBody("Test body"); |
|
||||
|
|
||||
boolean shouldArm = ttlEnforcer.validateBeforeArming(content); |
|
||||
|
|
||||
assertTrue("Should arm fresh content", shouldArm); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test validateBeforeArming with stale content |
|
||||
*/ |
|
||||
public void testValidateBeforeArmingStale() { |
|
||||
long currentTime = System.currentTimeMillis(); |
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); |
|
||||
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); |
|
||||
|
|
||||
NotificationContent content = new NotificationContent(); |
|
||||
content.setId("test_slot_5"); |
|
||||
content.setScheduledTime(scheduledTime); |
|
||||
content.setFetchedAt(fetchedAt); |
|
||||
content.setTitle("Test Notification"); |
|
||||
content.setBody("Test body"); |
|
||||
|
|
||||
boolean shouldArm = ttlEnforcer.validateBeforeArming(content); |
|
||||
|
|
||||
assertFalse("Should not arm stale content", shouldArm); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test edge case: content fetched exactly at TTL limit |
|
||||
*/ |
|
||||
public void testTTLBoundaryCase() { |
|
||||
long currentTime = System.currentTimeMillis(); |
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); |
|
||||
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(1); // Exactly 1 hour ago (TTL limit)
|
|
||||
|
|
||||
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_6", scheduledTime, fetchedAt); |
|
||||
|
|
||||
assertTrue("Content at TTL boundary should be considered fresh", isFresh); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test edge case: content fetched just over TTL limit |
|
||||
*/ |
|
||||
public void testTTLBoundaryCaseOver() { |
|
||||
long currentTime = System.currentTimeMillis(); |
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); |
|
||||
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(1) - TimeUnit.SECONDS.toMillis(1); // 1 hour + 1 second ago
|
|
||||
|
|
||||
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_7", scheduledTime, fetchedAt); |
|
||||
|
|
||||
assertFalse("Content just over TTL limit should be considered stale", isFresh); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test TTL violation statistics |
|
||||
*/ |
|
||||
public void testTTLViolationStats() { |
|
||||
// Generate some TTL violations
|
|
||||
long currentTime = System.currentTimeMillis(); |
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); |
|
||||
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); |
|
||||
|
|
||||
// Trigger TTL violations
|
|
||||
ttlEnforcer.isContentFresh("test_slot_8", scheduledTime, fetchedAt); |
|
||||
ttlEnforcer.isContentFresh("test_slot_9", scheduledTime, fetchedAt); |
|
||||
|
|
||||
String stats = ttlEnforcer.getTTLViolationStats(); |
|
||||
|
|
||||
assertNotNull("TTL violation stats should not be null", stats); |
|
||||
assertTrue("Stats should contain violation count", stats.contains("violations")); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test error handling with invalid parameters |
|
||||
*/ |
|
||||
public void testErrorHandling() { |
|
||||
// Test with null slot ID
|
|
||||
boolean result = ttlEnforcer.isContentFresh(null, System.currentTimeMillis(), System.currentTimeMillis()); |
|
||||
assertFalse("Should handle null slot ID gracefully", result); |
|
||||
|
|
||||
// Test with invalid timestamps
|
|
||||
result = ttlEnforcer.isContentFresh("test_slot_10", 0, 0); |
|
||||
assertTrue("Should handle invalid timestamps gracefully", result); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Test TTL configuration retrieval |
|
||||
*/ |
|
||||
public void testTTLConfiguration() { |
|
||||
// Test that TTL enforcer can retrieve configuration
|
|
||||
// This is indirectly tested through the freshness checks
|
|
||||
long currentTime = System.currentTimeMillis(); |
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); |
|
||||
long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(30); // 30 minutes ago
|
|
||||
|
|
||||
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_11", scheduledTime, fetchedAt); |
|
||||
|
|
||||
// Should be fresh (30 min < 1 hour TTL)
|
|
||||
assertTrue("Should retrieve TTL configuration correctly", isFresh); |
|
||||
} |
|
||||
} |
|
||||
@ -1,6 +1,7 @@ |
|||||
include ':app' |
// Settings file for Daily Notification Plugin |
||||
include ':plugin' |
// This is a minimal settings.gradle for a Capacitor plugin module |
||||
include ':capacitor-cordova-android-plugins' |
// Capacitor plugins don't typically need a settings.gradle, but it's included |
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') |
// for standalone builds and Android Studio compatibility |
||||
|
|
||||
|
rootProject.name = 'daily-notification-plugin' |
||||
|
|
||||
apply from: 'capacitor.settings.gradle' |
|
||||
@ -0,0 +1,9 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
package="com.timesafari.dailynotification.plugin"> |
||||
|
|
||||
|
<!-- Plugin receivers are declared in consuming app's manifest --> |
||||
|
<!-- This manifest is optional and mainly for library metadata --> |
||||
|
|
||||
|
</manifest> |
||||
|
|
||||
@ -0,0 +1,111 @@ |
|||||
|
#!/usr/bin/env node
|
||||
|
|
||||
|
/** |
||||
|
* Verify Capacitor plugin Android structure (post-restructure) |
||||
|
* |
||||
|
* This script verifies that the plugin follows the standard Capacitor structure: |
||||
|
* - android/src/main/java/... (plugin code) |
||||
|
* - android/build.gradle (plugin build config) |
||||
|
* |
||||
|
* This script is now optional since the plugin uses standard structure. |
||||
|
* It can be used to verify the structure is correct. |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
*/ |
||||
|
|
||||
|
import fs from 'fs'; |
||||
|
import path from 'path'; |
||||
|
import { fileURLToPath } from 'url'; |
||||
|
|
||||
|
const __filename = fileURLToPath(import.meta.url); |
||||
|
const __dirname = path.dirname(__filename); |
||||
|
|
||||
|
function findAppRoot() { |
||||
|
let currentDir = __dirname; |
||||
|
|
||||
|
// Go up from scripts/ to plugin root
|
||||
|
currentDir = path.dirname(currentDir); |
||||
|
|
||||
|
// Verify we're in the plugin root
|
||||
|
const pluginPackageJson = path.join(currentDir, 'package.json'); |
||||
|
if (!fs.existsSync(pluginPackageJson)) { |
||||
|
throw new Error('Could not find plugin package.json - script may be in wrong location'); |
||||
|
} |
||||
|
|
||||
|
// Go up from plugin root to node_modules/@timesafari
|
||||
|
currentDir = path.dirname(currentDir); |
||||
|
|
||||
|
// Go up from node_modules/@timesafari to node_modules
|
||||
|
currentDir = path.dirname(currentDir); |
||||
|
|
||||
|
// Go up from node_modules to app root
|
||||
|
const appRoot = path.dirname(currentDir); |
||||
|
|
||||
|
// Verify we found an app root
|
||||
|
const androidDir = path.join(appRoot, 'android'); |
||||
|
if (!fs.existsSync(androidDir)) { |
||||
|
throw new Error(`Could not find app android directory. Looked in: ${appRoot}`); |
||||
|
} |
||||
|
|
||||
|
return appRoot; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Verify plugin uses standard Capacitor structure |
||||
|
*/ |
||||
|
function verifyPluginStructure() { |
||||
|
console.log('🔍 Verifying Daily Notification Plugin structure...'); |
||||
|
|
||||
|
try { |
||||
|
const APP_ROOT = findAppRoot(); |
||||
|
const PLUGIN_PATH = path.join(APP_ROOT, 'node_modules', '@timesafari', 'daily-notification-plugin'); |
||||
|
const ANDROID_PLUGIN_PATH = path.join(PLUGIN_PATH, 'android'); |
||||
|
const PLUGIN_JAVA_PATH = path.join(ANDROID_PLUGIN_PATH, 'src', 'main', 'java'); |
||||
|
|
||||
|
if (!fs.existsSync(ANDROID_PLUGIN_PATH)) { |
||||
|
console.log('ℹ️ Plugin not found in node_modules (may not be installed yet)'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Check for standard structure
|
||||
|
const hasStandardStructure = fs.existsSync(PLUGIN_JAVA_PATH); |
||||
|
const hasOldStructure = fs.existsSync(path.join(ANDROID_PLUGIN_PATH, 'plugin')); |
||||
|
|
||||
|
if (hasOldStructure) { |
||||
|
console.log('⚠️ WARNING: Plugin still uses old structure (android/plugin/)'); |
||||
|
console.log(' This should not happen after restructure. Please rebuild plugin.'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (hasStandardStructure) { |
||||
|
console.log('✅ Plugin uses standard Capacitor structure (android/src/main/java/)'); |
||||
|
console.log(' No fixes needed - plugin path is correct!'); |
||||
|
} else { |
||||
|
console.log('⚠️ Plugin structure not recognized'); |
||||
|
console.log(` Expected: ${PLUGIN_JAVA_PATH}`); |
||||
|
} |
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('❌ Error verifying plugin structure:', error.message); |
||||
|
process.exit(1); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Run verification |
||||
|
*/ |
||||
|
function verifyAll() { |
||||
|
console.log('🔍 Daily Notification Plugin - Structure Verification'); |
||||
|
console.log('==================================================\n'); |
||||
|
|
||||
|
verifyPluginStructure(); |
||||
|
|
||||
|
console.log('\n✅ Verification complete!'); |
||||
|
} |
||||
|
|
||||
|
// Run if called directly
|
||||
|
if (import.meta.url === `file://${process.argv[1]}`) { |
||||
|
verifyAll(); |
||||
|
} |
||||
|
|
||||
|
export { verifyPluginStructure, verifyAll }; |
||||
@ -0,0 +1,235 @@ |
|||||
|
# Test Apps Build Process Review |
||||
|
|
||||
|
## Summary |
||||
|
|
||||
|
Both test apps are configured to **automatically build the plugin** as part of their build process. The plugin is included as a Gradle project dependency, so Gradle handles building it automatically. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Test App 1: `android-test-app` (Standalone Android) |
||||
|
|
||||
|
**Location**: `test-apps/android-test-app/` |
||||
|
|
||||
|
### Configuration |
||||
|
|
||||
|
**Plugin Reference** (`settings.gradle`): |
||||
|
```gradle |
||||
|
// Reference plugin from root project |
||||
|
def pluginPath = new File(settingsDir.parentFile.parentFile, 'android') |
||||
|
include ':daily-notification-plugin' |
||||
|
project(':daily-notification-plugin').projectDir = pluginPath |
||||
|
``` |
||||
|
|
||||
|
**Plugin Dependency** (`app/build.gradle`): |
||||
|
```gradle |
||||
|
dependencies { |
||||
|
implementation project(':capacitor-android') |
||||
|
implementation project(':daily-notification-plugin') // ✅ Plugin included |
||||
|
// Plugin dependencies also included |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Capacitor Setup**: |
||||
|
- References Capacitor from `daily-notification-test/node_modules/` (shared dependency) |
||||
|
- Includes `:capacitor-android` project module |
||||
|
|
||||
|
### Build Process |
||||
|
|
||||
|
1. **Gradle resolves plugin project** - Finds plugin at `../../android` |
||||
|
2. **Gradle builds plugin module** - Compiles plugin Java code to AAR (internally) |
||||
|
3. **Gradle builds app module** - Compiles app code |
||||
|
4. **Gradle links plugin** - Includes plugin classes in app APK |
||||
|
5. **Final output**: `app/build/outputs/apk/debug/app-debug.apk` |
||||
|
|
||||
|
### Build Commands |
||||
|
|
||||
|
```bash |
||||
|
cd test-apps/android-test-app |
||||
|
|
||||
|
# Build debug APK (builds plugin automatically) |
||||
|
./gradlew assembleDebug |
||||
|
|
||||
|
# Build release APK |
||||
|
./gradlew assembleRelease |
||||
|
|
||||
|
# Clean build |
||||
|
./gradlew clean |
||||
|
|
||||
|
# List tasks |
||||
|
./gradlew tasks |
||||
|
``` |
||||
|
|
||||
|
### Prerequisites |
||||
|
|
||||
|
- ✅ Gradle wrapper present (`gradlew`, `gradlew.bat`, `gradle/wrapper/`) |
||||
|
- ✅ Capacitor must be installed in `daily-notification-test/node_modules/` (shared) |
||||
|
- ✅ Plugin must exist at root `android/` directory |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Test App 2: `daily-notification-test` (Vue 3 + Capacitor) |
||||
|
|
||||
|
**Location**: `test-apps/daily-notification-test/` |
||||
|
|
||||
|
### Configuration |
||||
|
|
||||
|
**Plugin Installation** (`package.json`): |
||||
|
```json |
||||
|
{ |
||||
|
"dependencies": { |
||||
|
"@timesafari/daily-notification-plugin": "file:../../" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Capacitor Auto-Configuration**: |
||||
|
- `npx cap sync android` automatically: |
||||
|
1. Installs plugin from `file:../../` → `node_modules/@timesafari/daily-notification-plugin/` |
||||
|
2. Generates `capacitor.settings.gradle` with plugin reference |
||||
|
3. Generates `capacitor.build.gradle` with plugin dependency |
||||
|
4. Generates `capacitor.plugins.json` with plugin registration |
||||
|
|
||||
|
**Plugin Reference** (`capacitor.settings.gradle` - auto-generated): |
||||
|
```gradle |
||||
|
include ':timesafari-daily-notification-plugin' |
||||
|
project(':timesafari-daily-notification-plugin').projectDir = |
||||
|
new File('../node_modules/@timesafari/daily-notification-plugin/android') |
||||
|
``` |
||||
|
|
||||
|
**Plugin Dependency** (`capacitor.build.gradle` - auto-generated): |
||||
|
```gradle |
||||
|
dependencies { |
||||
|
implementation project(':timesafari-daily-notification-plugin') |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Build Process |
||||
|
|
||||
|
1. **npm install** - Installs plugin from `file:../../` to `node_modules/` |
||||
|
2. **npm run build** - Builds Vue 3 web app → `dist/` |
||||
|
3. **npx cap sync android** - Capacitor: |
||||
|
- Copies web assets to `android/app/src/main/assets/` |
||||
|
- Configures plugin in Gradle files |
||||
|
- Registers plugin in `capacitor.plugins.json` |
||||
|
4. **Fix script runs** - Verifies plugin path is correct (post-sync hook) |
||||
|
5. **Gradle builds** - Plugin is built as part of app build |
||||
|
6. **Final output**: `android/app/build/outputs/apk/debug/app-debug.apk` |
||||
|
|
||||
|
### Build Commands |
||||
|
|
||||
|
```bash |
||||
|
cd test-apps/daily-notification-test |
||||
|
|
||||
|
# Initial setup (one-time) |
||||
|
npm install # Installs plugin from file:../../ |
||||
|
npx cap sync android # Configures Android build |
||||
|
|
||||
|
# Development workflow |
||||
|
npm run build # Builds Vue 3 web app |
||||
|
npx cap sync android # Syncs web assets + plugin config |
||||
|
cd android |
||||
|
./gradlew assembleDebug # Builds Android app (includes plugin) |
||||
|
|
||||
|
# Or use Capacitor CLI (does everything) |
||||
|
npx cap run android # Builds web + syncs + builds Android + runs |
||||
|
``` |
||||
|
|
||||
|
### Post-Install Hook |
||||
|
|
||||
|
The `postinstall` script (`scripts/fix-capacitor-plugins.js`) automatically: |
||||
|
- ✅ Verifies plugin is registered in `capacitor.plugins.json` |
||||
|
- ✅ Verifies plugin path in `capacitor.settings.gradle` points to `android/` (standard structure) |
||||
|
- ✅ Fixes path if it incorrectly points to old `android/plugin/` structure |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Key Points |
||||
|
|
||||
|
### ✅ Both Apps Build Plugin Automatically |
||||
|
|
||||
|
- **No manual plugin build needed** - Gradle handles it |
||||
|
- **Plugin is a project dependency** - Built before the app |
||||
|
- **Standard Gradle behavior** - Works like any Android library module |
||||
|
|
||||
|
### ✅ Plugin Structure is Standard |
||||
|
|
||||
|
- **Plugin location**: `android/src/main/java/...` (standard Capacitor structure) |
||||
|
- **No path fixes needed** - Capacitor auto-generates correct paths |
||||
|
- **Works with `npx cap sync`** - No manual configuration required |
||||
|
|
||||
|
### ✅ Build Dependencies |
||||
|
|
||||
|
**android-test-app**: |
||||
|
- Requires Capacitor from `daily-notification-test/node_modules/` (shared) |
||||
|
- References plugin directly from root `android/` directory |
||||
|
|
||||
|
**daily-notification-test**: |
||||
|
- Requires `npm install` to install plugin |
||||
|
- Requires `npx cap sync android` to configure build |
||||
|
- Plugin installed to `node_modules/` like any npm package |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Verification |
||||
|
|
||||
|
### Check Plugin is Included |
||||
|
|
||||
|
```bash |
||||
|
# For android-test-app |
||||
|
cd test-apps/android-test-app |
||||
|
./gradlew :app:dependencies | grep daily-notification |
||||
|
|
||||
|
# For daily-notification-test |
||||
|
cd test-apps/daily-notification-test/android |
||||
|
./gradlew :app:dependencies | grep timesafari |
||||
|
``` |
||||
|
|
||||
|
### Check Plugin Registration |
||||
|
|
||||
|
```bash |
||||
|
# Vue app only |
||||
|
cat test-apps/daily-notification-test/android/app/src/main/assets/capacitor.plugins.json |
||||
|
``` |
||||
|
|
||||
|
Should contain: |
||||
|
```json |
||||
|
[ |
||||
|
{ |
||||
|
"name": "DailyNotification", |
||||
|
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin" |
||||
|
} |
||||
|
] |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Troubleshooting |
||||
|
|
||||
|
### android-test-app: "Capacitor not found" |
||||
|
|
||||
|
**Solution**: Run `npm install` in `test-apps/daily-notification-test/` first to install Capacitor dependencies. |
||||
|
|
||||
|
### android-test-app: "Plugin not found" |
||||
|
|
||||
|
**Solution**: Verify `android/build.gradle` exists at the root project level. |
||||
|
|
||||
|
### daily-notification-test: Plugin path wrong |
||||
|
|
||||
|
**Solution**: Run `node scripts/fix-capacitor-plugins.js` after `npx cap sync android`. The script now verifies/fixes the path to use standard `android/` structure. |
||||
|
|
||||
|
### Both: Build succeeds but plugin doesn't work |
||||
|
|
||||
|
**Solution**: |
||||
|
- Check `capacitor.plugins.json` has plugin registered |
||||
|
- Verify plugin classes are in the APK: `unzip -l app-debug.apk | grep DailyNotification` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Summary |
||||
|
|
||||
|
✅ **Both test apps handle plugin building automatically** |
||||
|
✅ **Plugin uses standard Capacitor structure** (`android/src/main/java/`) |
||||
|
✅ **No manual plugin builds required** - Gradle handles dependencies |
||||
|
✅ **Build processes are configured correctly** - Ready to use |
||||
|
|
||||
|
The test apps are properly configured to build and test the plugin! |
||||
@ -0,0 +1,16 @@ |
|||||
|
{ |
||||
|
"appId": "com.timesafari.dailynotification", |
||||
|
"appName": "DailyNotification Test App", |
||||
|
"webDir": "www", |
||||
|
"server": { |
||||
|
"androidScheme": "https" |
||||
|
}, |
||||
|
"plugins": { |
||||
|
"DailyNotification": { |
||||
|
"fetchUrl": "https://api.example.com/daily-content", |
||||
|
"scheduleTime": "09:00", |
||||
|
"enableNotifications": true, |
||||
|
"debugMode": true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,575 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="en"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"> |
||||
|
<meta http-equiv="Pragma" content="no-cache"> |
||||
|
<meta http-equiv="Expires" content="0"> |
||||
|
<title>DailyNotification Plugin Test</title> |
||||
|
<style> |
||||
|
body { |
||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
||||
|
margin: 0; |
||||
|
padding: 20px; |
||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
|
min-height: 100vh; |
||||
|
color: white; |
||||
|
} |
||||
|
.container { |
||||
|
max-width: 600px; |
||||
|
margin: 0 auto; |
||||
|
text-align: center; |
||||
|
} |
||||
|
h1 { |
||||
|
margin-bottom: 30px; |
||||
|
font-size: 2.5em; |
||||
|
} |
||||
|
.button { |
||||
|
background: rgba(255, 255, 255, 0.2); |
||||
|
border: 2px solid rgba(255, 255, 255, 0.3); |
||||
|
color: white; |
||||
|
padding: 15px 30px; |
||||
|
margin: 10px; |
||||
|
border-radius: 25px; |
||||
|
cursor: pointer; |
||||
|
font-size: 16px; |
||||
|
transition: all 0.3s ease; |
||||
|
} |
||||
|
.button:hover { |
||||
|
background: rgba(255, 255, 255, 0.3); |
||||
|
transform: translateY(-2px); |
||||
|
} |
||||
|
.status { |
||||
|
margin-top: 30px; |
||||
|
padding: 20px; |
||||
|
background: rgba(255, 255, 255, 0.1); |
||||
|
border-radius: 10px; |
||||
|
font-family: monospace; |
||||
|
} |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div class="container"> |
||||
|
<h1>🔔 DailyNotification Plugin Test</h1> |
||||
|
<p>Test the DailyNotification plugin functionality</p> |
||||
|
<p style="font-size: 12px; opacity: 0.8;">Build: 2025-10-14 05:00:00 UTC</p> |
||||
|
|
||||
|
<button class="button" onclick="testPlugin()">Test Plugin</button> |
||||
|
<button class="button" onclick="configurePlugin()">Configure Plugin</button> |
||||
|
<button class="button" onclick="checkStatus()">Check Status</button> |
||||
|
|
||||
|
<h2>🔔 Notification Tests</h2> |
||||
|
<button class="button" onclick="testNotification()">Test Notification</button> |
||||
|
<button class="button" onclick="scheduleNotification()">Schedule Notification</button> |
||||
|
<button class="button" onclick="showReminder()">Show Reminder</button> |
||||
|
|
||||
|
<h2>🔐 Permission Management</h2> |
||||
|
<button class="button" onclick="checkPermissions()">Check Permissions</button> |
||||
|
<button class="button" onclick="requestPermissions()">Request Permissions</button> |
||||
|
<button class="button" onclick="openExactAlarmSettings()">Exact Alarm Settings</button> |
||||
|
|
||||
|
<h2>📢 Channel Management</h2> |
||||
|
<button class="button" onclick="checkChannelStatus()">Check Channel Status</button> |
||||
|
<button class="button" onclick="openChannelSettings()">Open Channel Settings</button> |
||||
|
<button class="button" onclick="checkComprehensiveStatus()">Comprehensive Status</button> |
||||
|
|
||||
|
<div id="status" class="status"> |
||||
|
Ready to test... |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
console.log('Script loading...'); |
||||
|
console.log('JavaScript is working!'); |
||||
|
|
||||
|
// Use real DailyNotification plugin |
||||
|
console.log('Using real DailyNotification plugin...'); |
||||
|
window.DailyNotification = window.Capacitor.Plugins.DailyNotification; |
||||
|
|
||||
|
// Define functions immediately and attach to window |
||||
|
function testPlugin() { |
||||
|
console.log('testPlugin called'); |
||||
|
const status = document.getElementById('status'); |
||||
|
status.innerHTML = 'Testing plugin...'; |
||||
|
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background |
||||
|
|
||||
|
try { |
||||
|
if (!window.DailyNotification) { |
||||
|
status.innerHTML = 'DailyNotification plugin not available'; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
return; |
||||
|
} |
||||
|
// Plugin is loaded and ready |
||||
|
status.innerHTML = 'Plugin is loaded and ready!'; |
||||
|
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background |
||||
|
} catch (error) { |
||||
|
status.innerHTML = `Plugin test failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function configurePlugin() { |
||||
|
console.log('configurePlugin called'); |
||||
|
const status = document.getElementById('status'); |
||||
|
status.innerHTML = 'Configuring plugin...'; |
||||
|
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background |
||||
|
|
||||
|
try { |
||||
|
if (!window.DailyNotification) { |
||||
|
status.innerHTML = 'DailyNotification plugin not available'; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Configure plugin settings |
||||
|
window.DailyNotification.configure({ |
||||
|
storage: 'tiered', |
||||
|
ttlSeconds: 86400, |
||||
|
prefetchLeadMinutes: 60, |
||||
|
maxNotificationsPerDay: 3, |
||||
|
retentionDays: 7 |
||||
|
}) |
||||
|
.then(() => { |
||||
|
console.log('Plugin settings configured, now configuring native fetcher...'); |
||||
|
// Configure native fetcher with demo credentials |
||||
|
// Note: DemoNativeFetcher uses hardcoded mock data, so this is optional |
||||
|
// but demonstrates the API. In production, this would be real credentials. |
||||
|
return window.DailyNotification.configureNativeFetcher({ |
||||
|
apiBaseUrl: 'http://10.0.2.2:3000', // Android emulator → host localhost |
||||
|
activeDid: 'did:ethr:0xDEMO1234567890', // Demo DID |
||||
|
jwtSecret: 'demo-jwt-secret-for-development-testing' |
||||
|
}); |
||||
|
}) |
||||
|
.then(() => { |
||||
|
status.innerHTML = 'Plugin configured successfully!<br>✅ Plugin settings<br>✅ Native fetcher (optional for demo)'; |
||||
|
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
status.innerHTML = `Configuration failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
status.innerHTML = `Configuration failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function checkStatus() { |
||||
|
console.log('checkStatus called'); |
||||
|
const status = document.getElementById('status'); |
||||
|
status.innerHTML = 'Checking plugin status...'; |
||||
|
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background |
||||
|
|
||||
|
try { |
||||
|
if (!window.DailyNotification) { |
||||
|
status.innerHTML = 'DailyNotification plugin not available'; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
return; |
||||
|
} |
||||
|
window.DailyNotification.getNotificationStatus() |
||||
|
.then(result => { |
||||
|
const nextTime = result.nextNotificationTime ? new Date(result.nextNotificationTime).toLocaleString() : 'None scheduled'; |
||||
|
status.innerHTML = `Plugin Status:<br> |
||||
|
Enabled: ${result.isEnabled}<br> |
||||
|
Next Notification: ${nextTime}<br> |
||||
|
Pending: ${result.pending}<br> |
||||
|
Settings: ${JSON.stringify(result.settings)}`; |
||||
|
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
status.innerHTML = `Status check failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
status.innerHTML = `Status check failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Notification test functions |
||||
|
function testNotification() { |
||||
|
console.log('testNotification called'); |
||||
|
|
||||
|
// Quick sanity check - test plugin availability |
||||
|
if (window.Capacitor && window.Capacitor.isPluginAvailable) { |
||||
|
const isAvailable = window.Capacitor.isPluginAvailable('DailyNotification'); |
||||
|
console.log('is plugin available?', isAvailable); |
||||
|
} |
||||
|
|
||||
|
const status = document.getElementById('status'); |
||||
|
status.innerHTML = 'Testing plugin connection...'; |
||||
|
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background |
||||
|
|
||||
|
try { |
||||
|
if (!window.DailyNotification) { |
||||
|
status.innerHTML = 'DailyNotification plugin not available'; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Test the notification method directly |
||||
|
console.log('Testing notification scheduling...'); |
||||
|
const now = new Date(); |
||||
|
const notificationTime = new Date(now.getTime() + 600000); // 10 minutes from now |
||||
|
const prefetchTime = new Date(now.getTime() + 300000); // 5 minutes from now |
||||
|
const notificationTimeString = notificationTime.getHours().toString().padStart(2, '0') + ':' + |
||||
|
notificationTime.getMinutes().toString().padStart(2, '0'); |
||||
|
const prefetchTimeString = prefetchTime.getHours().toString().padStart(2, '0') + ':' + |
||||
|
prefetchTime.getMinutes().toString().padStart(2, '0'); |
||||
|
|
||||
|
window.DailyNotification.scheduleDailyNotification({ |
||||
|
time: notificationTimeString, |
||||
|
title: 'Test Notification', |
||||
|
body: 'This is a test notification from the DailyNotification plugin!', |
||||
|
sound: true, |
||||
|
priority: 'high' |
||||
|
}) |
||||
|
.then(() => { |
||||
|
const prefetchTimeReadable = prefetchTime.toLocaleTimeString(); |
||||
|
const notificationTimeReadable = notificationTime.toLocaleTimeString(); |
||||
|
status.innerHTML = '✅ Notification scheduled!<br>' + |
||||
|
'📥 Prefetch: ' + prefetchTimeReadable + ' (' + prefetchTimeString + ')<br>' + |
||||
|
'🔔 Notification: ' + notificationTimeReadable + ' (' + notificationTimeString + ')'; |
||||
|
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
status.innerHTML = `Notification failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
status.innerHTML = `Notification test failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function scheduleNotification() { |
||||
|
console.log('scheduleNotification called'); |
||||
|
const status = document.getElementById('status'); |
||||
|
status.innerHTML = 'Scheduling notification...'; |
||||
|
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background |
||||
|
|
||||
|
try { |
||||
|
if (!window.DailyNotification) { |
||||
|
status.innerHTML = 'DailyNotification plugin not available'; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Schedule notification for 10 minutes from now (allows 5 min prefetch to fire) |
||||
|
const now = new Date(); |
||||
|
const notificationTime = new Date(now.getTime() + 600000); // 10 minutes from now |
||||
|
const prefetchTime = new Date(now.getTime() + 300000); // 5 minutes from now |
||||
|
const notificationTimeString = notificationTime.getHours().toString().padStart(2, '0') + ':' + |
||||
|
notificationTime.getMinutes().toString().padStart(2, '0'); |
||||
|
const prefetchTimeString = prefetchTime.getHours().toString().padStart(2, '0') + ':' + |
||||
|
prefetchTime.getMinutes().toString().padStart(2, '0'); |
||||
|
|
||||
|
window.DailyNotification.scheduleDailyNotification({ |
||||
|
time: notificationTimeString, |
||||
|
title: 'Scheduled Notification', |
||||
|
body: 'This notification was scheduled 10 minutes ago!', |
||||
|
sound: true, |
||||
|
priority: 'default' |
||||
|
}) |
||||
|
.then(() => { |
||||
|
const prefetchTimeReadable = prefetchTime.toLocaleTimeString(); |
||||
|
const notificationTimeReadable = notificationTime.toLocaleTimeString(); |
||||
|
status.innerHTML = '✅ Notification scheduled!<br>' + |
||||
|
'📥 Prefetch: ' + prefetchTimeReadable + ' (' + prefetchTimeString + ')<br>' + |
||||
|
'🔔 Notification: ' + notificationTimeReadable + ' (' + notificationTimeString + ')'; |
||||
|
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
status.innerHTML = `Scheduling failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
status.innerHTML = `Scheduling test failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function showReminder() { |
||||
|
console.log('showReminder called'); |
||||
|
const status = document.getElementById('status'); |
||||
|
status.innerHTML = 'Showing reminder...'; |
||||
|
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background |
||||
|
|
||||
|
try { |
||||
|
if (!window.DailyNotification) { |
||||
|
status.innerHTML = 'DailyNotification plugin not available'; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Schedule daily reminder using scheduleDailyReminder |
||||
|
const now = new Date(); |
||||
|
const reminderTime = new Date(now.getTime() + 10000); // 10 seconds from now |
||||
|
const timeString = reminderTime.getHours().toString().padStart(2, '0') + ':' + |
||||
|
reminderTime.getMinutes().toString().padStart(2, '0'); |
||||
|
|
||||
|
window.DailyNotification.scheduleDailyReminder({ |
||||
|
id: 'daily-reminder-test', |
||||
|
title: 'Daily Reminder', |
||||
|
body: 'Don\'t forget to check your daily notifications!', |
||||
|
time: timeString, |
||||
|
sound: true, |
||||
|
vibration: true, |
||||
|
priority: 'default', |
||||
|
repeatDaily: false // Just for testing |
||||
|
}) |
||||
|
.then(() => { |
||||
|
status.innerHTML = 'Daily reminder scheduled for ' + timeString + '!'; |
||||
|
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
status.innerHTML = `Reminder failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
status.innerHTML = `Reminder test failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Permission management functions |
||||
|
function checkPermissions() { |
||||
|
console.log('checkPermissions called'); |
||||
|
const status = document.getElementById('status'); |
||||
|
status.innerHTML = 'Checking permissions...'; |
||||
|
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background |
||||
|
|
||||
|
try { |
||||
|
if (!window.DailyNotification) { |
||||
|
status.innerHTML = 'DailyNotification plugin not available'; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
window.DailyNotification.checkPermissionStatus() |
||||
|
.then(result => { |
||||
|
status.innerHTML = `Permission Status:<br> |
||||
|
Notifications: ${result.notificationsEnabled ? '✅' : '❌'}<br> |
||||
|
Exact Alarm: ${result.exactAlarmEnabled ? '✅' : '❌'}<br> |
||||
|
Wake Lock: ${result.wakeLockEnabled ? '✅' : '❌'}<br> |
||||
|
All Granted: ${result.allPermissionsGranted ? '✅' : '❌'}`; |
||||
|
status.style.background = result.allPermissionsGranted ? |
||||
|
'rgba(0, 255, 0, 0.3)' : 'rgba(255, 165, 0, 0.3)'; // Green or orange |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
status.innerHTML = `Permission check failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
status.innerHTML = `Permission check failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function requestPermissions() { |
||||
|
console.log('requestPermissions called'); |
||||
|
const status = document.getElementById('status'); |
||||
|
status.innerHTML = 'Requesting permissions...'; |
||||
|
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background |
||||
|
|
||||
|
try { |
||||
|
if (!window.DailyNotification) { |
||||
|
status.innerHTML = 'DailyNotification plugin not available'; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
window.DailyNotification.requestNotificationPermissions() |
||||
|
.then(() => { |
||||
|
status.innerHTML = 'Permission request completed! Check your device settings if needed.'; |
||||
|
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background |
||||
|
|
||||
|
// Check permissions again after request |
||||
|
setTimeout(() => { |
||||
|
checkPermissions(); |
||||
|
}, 1000); |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
status.innerHTML = `Permission request failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
status.innerHTML = `Permission request failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function openExactAlarmSettings() { |
||||
|
console.log('openExactAlarmSettings called'); |
||||
|
const status = document.getElementById('status'); |
||||
|
status.innerHTML = 'Opening exact alarm settings...'; |
||||
|
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background |
||||
|
|
||||
|
try { |
||||
|
if (!window.DailyNotification) { |
||||
|
status.innerHTML = 'DailyNotification plugin not available'; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
window.DailyNotification.openExactAlarmSettings() |
||||
|
.then(() => { |
||||
|
status.innerHTML = 'Exact alarm settings opened! Please enable "Allow exact alarms" and return to the app.'; |
||||
|
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
status.innerHTML = `Failed to open exact alarm settings: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
status.innerHTML = `Failed to open exact alarm settings: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function checkChannelStatus() { |
||||
|
const status = document.getElementById('status'); |
||||
|
status.innerHTML = 'Checking channel status...'; |
||||
|
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background |
||||
|
|
||||
|
try { |
||||
|
if (!window.DailyNotification) { |
||||
|
status.innerHTML = 'DailyNotification plugin not available'; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
window.DailyNotification.isChannelEnabled() |
||||
|
.then(result => { |
||||
|
const importanceText = getImportanceText(result.importance); |
||||
|
status.innerHTML = `Channel Status: ${result.enabled ? 'Enabled' : 'Disabled'} (${importanceText})`; |
||||
|
status.style.background = result.enabled ? 'rgba(0, 255, 0, 0.3)' : 'rgba(255, 0, 0, 0.3)'; |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
status.innerHTML = `Channel check failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
status.innerHTML = `Channel check failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function openChannelSettings() { |
||||
|
const status = document.getElementById('status'); |
||||
|
status.innerHTML = 'Opening channel settings...'; |
||||
|
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background |
||||
|
|
||||
|
try { |
||||
|
if (!window.DailyNotification) { |
||||
|
status.innerHTML = 'DailyNotification plugin not available'; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
window.DailyNotification.openChannelSettings() |
||||
|
.then(result => { |
||||
|
if (result.opened) { |
||||
|
status.innerHTML = 'Channel settings opened! Please enable notifications and return to the app.'; |
||||
|
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background |
||||
|
} else { |
||||
|
status.innerHTML = 'Could not open channel settings (may not be available on this device)'; |
||||
|
status.style.background = 'rgba(255, 165, 0, 0.3)'; // Orange background |
||||
|
} |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
status.innerHTML = `Failed to open channel settings: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
status.innerHTML = `Failed to open channel settings: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function checkComprehensiveStatus() { |
||||
|
const status = document.getElementById('status'); |
||||
|
status.innerHTML = 'Checking comprehensive status...'; |
||||
|
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background |
||||
|
|
||||
|
try { |
||||
|
if (!window.DailyNotification) { |
||||
|
status.innerHTML = 'DailyNotification plugin not available'; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
window.DailyNotification.checkStatus() |
||||
|
.then(result => { |
||||
|
const canSchedule = result.canScheduleNow; |
||||
|
const issues = []; |
||||
|
|
||||
|
if (!result.postNotificationsGranted) { |
||||
|
issues.push('POST_NOTIFICATIONS permission'); |
||||
|
} |
||||
|
if (!result.channelEnabled) { |
||||
|
issues.push('notification channel disabled'); |
||||
|
} |
||||
|
if (!result.exactAlarmsGranted) { |
||||
|
issues.push('exact alarm permission'); |
||||
|
} |
||||
|
|
||||
|
let statusText = `Status: ${canSchedule ? 'Ready to schedule' : 'Issues found'}`; |
||||
|
if (issues.length > 0) { |
||||
|
statusText += `\nIssues: ${issues.join(', ')}`; |
||||
|
} |
||||
|
|
||||
|
statusText += `\nChannel: ${getImportanceText(result.channelImportance)}`; |
||||
|
statusText += `\nChannel ID: ${result.channelId}`; |
||||
|
|
||||
|
status.innerHTML = statusText; |
||||
|
status.style.background = canSchedule ? 'rgba(0, 255, 0, 0.3)' : 'rgba(255, 0, 0, 0.3)'; |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
status.innerHTML = `Status check failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
status.innerHTML = `Status check failed: ${error.message}`; |
||||
|
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function getImportanceText(importance) { |
||||
|
switch (importance) { |
||||
|
case 0: return 'None (blocked)'; |
||||
|
case 1: return 'Min'; |
||||
|
case 2: return 'Low'; |
||||
|
case 3: return 'Default'; |
||||
|
case 4: return 'High'; |
||||
|
case 5: return 'Max'; |
||||
|
default: return `Unknown (${importance})`; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Attach to window object |
||||
|
window.testPlugin = testPlugin; |
||||
|
window.configurePlugin = configurePlugin; |
||||
|
window.checkStatus = checkStatus; |
||||
|
window.testNotification = testNotification; |
||||
|
window.scheduleNotification = scheduleNotification; |
||||
|
window.showReminder = showReminder; |
||||
|
window.checkPermissions = checkPermissions; |
||||
|
window.requestPermissions = requestPermissions; |
||||
|
window.openExactAlarmSettings = openExactAlarmSettings; |
||||
|
window.checkChannelStatus = checkChannelStatus; |
||||
|
window.openChannelSettings = openChannelSettings; |
||||
|
window.checkComprehensiveStatus = checkComprehensiveStatus; |
||||
|
|
||||
|
console.log('Functions attached to window:', { |
||||
|
testPlugin: typeof window.testPlugin, |
||||
|
configurePlugin: typeof window.configurePlugin, |
||||
|
checkStatus: typeof window.checkStatus, |
||||
|
testNotification: typeof window.testNotification, |
||||
|
scheduleNotification: typeof window.scheduleNotification, |
||||
|
showReminder: typeof window.showReminder |
||||
|
}); |
||||
|
</script> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,6 @@ |
|||||
|
[ |
||||
|
{ |
||||
|
"name": "DailyNotification", |
||||
|
"class": "com.timesafari.dailynotification.DailyNotificationPlugin" |
||||
|
} |
||||
|
] |
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |