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 { |
|||
|
|||
repositories { |
|||
google() |
|||
mavenCentral() |
|||
} |
|||
dependencies { |
|||
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" |
|||
} |
|||
|
|||
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 |
|||
} |
|||
} |
|||
|
|||
// NOTE: Do not place your application dependencies here; they belong |
|||
// in the individual module build.gradle files |
|||
// Exclude test sources from compilation |
|||
sourceSets { |
|||
test { |
|||
java { |
|||
srcDirs = [] // Disable test source compilation |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
apply from: "variables.gradle" |
|||
repositories { |
|||
google() |
|||
mavenCentral() |
|||
|
|||
allprojects { |
|||
repositories { |
|||
google() |
|||
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) { |
|||
delete rootProject.buildDir |
|||
dependencies { |
|||
// 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' |
|||
include ':plugin' |
|||
include ':capacitor-cordova-android-plugins' |
|||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') |
|||
// Settings file for Daily Notification Plugin |
|||
// This is a minimal settings.gradle for a Capacitor plugin module |
|||
// Capacitor plugins don't typically need a settings.gradle, but it's included |
|||
// 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 |