Browse Source

fix: resolve Android build issues and create proper plugin module

- Fix capacitor.build.gradle to comment out missing Capacitor integration file
- Create separate plugin module with proper build.gradle configuration
- Copy plugin source code to android/plugin/src/main/java/
- Update settings.gradle to include plugin module
- Fix plugin build.gradle to remove Kotlin plugin dependency
- Successfully build plugin AAR: android/plugin/build/outputs/aar/plugin-release.aar
- Update BUILDING.md with correct build commands and troubleshooting

This resolves the Android Studio build issues by creating a proper
plugin library module separate from the test app.
master
Matthew Raymer 2 weeks ago
parent
commit
300bd7f01f
  1. 39
      BUILDING.md
  2. 153
      android/android/plugin/src/main/java/com/timesafari/dailynotification/BootReceiver.kt
  3. 294
      android/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt
  4. 144
      android/android/plugin/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt
  5. 202
      android/android/plugin/src/main/java/com/timesafari/dailynotification/FetchWorker.kt
  6. 336
      android/android/plugin/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt
  7. 3
      android/app/capacitor.build.gradle
  8. 42
      android/plugin/build.gradle
  9. 1
      android/settings.gradle

39
BUILDING.md

@ -104,11 +104,14 @@ npm run build:watch
# Navigate to Android directory
cd android
# Build using Gradle Wrapper
./gradlew build
# Build plugin library (recommended for plugin development)
./gradlew :plugin:assembleRelease
# Build release AAR
./gradlew assembleRelease
# Build test app (requires proper Capacitor integration)
./gradlew :app:assembleRelease
# Build all modules
./gradlew build
# Run tests
./gradlew test
@ -189,7 +192,7 @@ Build → Generate Signed Bundle / APK
#### Build Output
The built plugin AAR will be located at:
```
android/build/outputs/aar/android-release.aar
android/plugin/build/outputs/aar/plugin-release.aar
```
### Project Structure in Android Studio
@ -202,13 +205,17 @@ android/
│ ├── build.gradle
│ ├── src/main/
│ └── ...
├── src/main/java/ # Plugin source code
│ └── com/timesafari/dailynotification/
│ ├── DailyNotificationPlugin.kt
│ ├── DailyNotificationBackgroundTask.kt
│ ├── DailyNotificationConfig.kt
│ └── ...
├── build.gradle # Module build file
├── plugin/ # Plugin library module
│ ├── build.gradle
│ ├── src/main/java/
│ │ └── com/timesafari/dailynotification/
│ │ ├── DailyNotificationPlugin.kt
│ │ ├── FetchWorker.kt
│ │ ├── NotifyReceiver.kt
│ │ └── ...
│ └── build/outputs/aar/
│ └── plugin-release.aar # Built plugin AAR
├── build.gradle # Root build file
├── settings.gradle # Project settings
├── gradle.properties # Gradle properties
└── gradle/wrapper/ # Gradle wrapper files
@ -493,6 +500,14 @@ rm -rf ~/.gradle/caches/
# Solution: This is a plugin development project
# The build script handles this automatically
./scripts/build-native.sh --platform android
# Problem: Build fails with "Could not resolve project :capacitor-cordova-android-plugins"
# Solution: Build the plugin module instead of the test app
./gradlew :plugin:assembleRelease
# Problem: Build fails with "Plugin with id 'kotlin-android' not found"
# Solution: The plugin module doesn't need Kotlin plugin for Java/Kotlin code
# Check the plugin/build.gradle file
```
#### Android Studio Issues

153
android/android/plugin/src/main/java/com/timesafari/dailynotification/BootReceiver.kt

@ -0,0 +1,153 @@
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
)

294
android/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt

@ -0,0 +1,294 @@
package com.timesafari.dailynotification
import android.content.Context
import android.util.Log
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
/**
* Main Android implementation of Daily Notification Plugin
* Bridges Capacitor calls to native Android functionality
*
* @author Matthew Raymer
* @version 1.1.0
*/
@CapacitorPlugin(name = "DailyNotification")
class DailyNotificationPlugin : Plugin() {
companion object {
private const val TAG = "DNP-PLUGIN"
}
private lateinit var db: DailyNotificationDatabase
override fun load() {
super.load()
db = DailyNotificationDatabase.getDatabase(context)
Log.i(TAG, "Daily Notification Plugin loaded")
}
@PluginMethod
fun configure(call: PluginCall) {
try {
val options = call.getObject("options")
Log.i(TAG, "Configure called with options: $options")
// Store configuration in database
CoroutineScope(Dispatchers.IO).launch {
try {
// Implementation would store config in database
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to configure", e)
call.reject("Configuration failed: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Configure error", e)
call.reject("Configuration error: ${e.message}")
}
}
@PluginMethod
fun scheduleContentFetch(call: PluginCall) {
try {
val configJson = call.getObject("config")
val config = parseContentFetchConfig(configJson)
Log.i(TAG, "Scheduling content fetch")
CoroutineScope(Dispatchers.IO).launch {
try {
// Schedule WorkManager fetch
FetchWorker.scheduleFetch(context, config)
// Store schedule in database
val schedule = Schedule(
id = "fetch_${System.currentTimeMillis()}",
kind = "fetch",
cron = config.schedule,
enabled = config.enabled,
nextRunAt = calculateNextRunTime(config.schedule)
)
db.scheduleDao().upsert(schedule)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule content fetch", e)
call.reject("Content fetch scheduling failed: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Schedule content fetch error", e)
call.reject("Content fetch error: ${e.message}")
}
}
@PluginMethod
fun scheduleUserNotification(call: PluginCall) {
try {
val configJson = call.getObject("config")
val config = parseUserNotificationConfig(configJson)
Log.i(TAG, "Scheduling user notification")
CoroutineScope(Dispatchers.IO).launch {
try {
val nextRunTime = calculateNextRunTime(config.schedule)
// Schedule AlarmManager notification
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
// Store schedule in database
val schedule = Schedule(
id = "notify_${System.currentTimeMillis()}",
kind = "notify",
cron = config.schedule,
enabled = config.enabled,
nextRunAt = nextRunTime
)
db.scheduleDao().upsert(schedule)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule user notification", e)
call.reject("User notification scheduling failed: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Schedule user notification error", e)
call.reject("User notification error: ${e.message}")
}
}
@PluginMethod
fun scheduleDualNotification(call: PluginCall) {
try {
val configJson = call.getObject("config")
val contentFetchConfig = parseContentFetchConfig(configJson.getObject("contentFetch"))
val userNotificationConfig = parseUserNotificationConfig(configJson.getObject("userNotification"))
Log.i(TAG, "Scheduling dual notification")
CoroutineScope(Dispatchers.IO).launch {
try {
// Schedule both fetch and notification
FetchWorker.scheduleFetch(context, contentFetchConfig)
val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
NotifyReceiver.scheduleExactNotification(context, nextRunTime, userNotificationConfig)
// Store both schedules
val fetchSchedule = Schedule(
id = "dual_fetch_${System.currentTimeMillis()}",
kind = "fetch",
cron = contentFetchConfig.schedule,
enabled = contentFetchConfig.enabled,
nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)
)
val notifySchedule = Schedule(
id = "dual_notify_${System.currentTimeMillis()}",
kind = "notify",
cron = userNotificationConfig.schedule,
enabled = userNotificationConfig.enabled,
nextRunAt = nextRunTime
)
db.scheduleDao().upsert(fetchSchedule)
db.scheduleDao().upsert(notifySchedule)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule dual notification", e)
call.reject("Dual notification scheduling failed: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Schedule dual notification error", e)
call.reject("Dual notification error: ${e.message}")
}
}
@PluginMethod
fun getDualScheduleStatus(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {
try {
val enabledSchedules = db.scheduleDao().getEnabled()
val latestCache = db.contentCacheDao().getLatest()
val recentHistory = db.historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L))
val status = JSObject().apply {
put("nextRuns", enabledSchedules.map { it.nextRunAt })
put("lastOutcomes", recentHistory.map { it.outcome })
put("cacheAgeMs", latestCache?.let { System.currentTimeMillis() - it.fetchedAt })
put("staleArmed", latestCache?.let {
System.currentTimeMillis() > (it.fetchedAt + it.ttlSeconds * 1000L)
} ?: true)
put("queueDepth", recentHistory.size)
}
call.resolve(status)
} catch (e: Exception) {
Log.e(TAG, "Failed to get dual schedule status", e)
call.reject("Status retrieval failed: ${e.message}")
}
}
}
@PluginMethod
fun registerCallback(call: PluginCall) {
try {
val name = call.getString("name")
val callback = call.getObject("callback")
Log.i(TAG, "Registering callback: $name")
CoroutineScope(Dispatchers.IO).launch {
try {
val callbackRecord = Callback(
id = name,
kind = callback.getString("kind", "local"),
target = callback.getString("target", ""),
headersJson = callback.getString("headers"),
enabled = true,
createdAt = System.currentTimeMillis()
)
db.callbackDao().upsert(callbackRecord)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to register callback", e)
call.reject("Callback registration failed: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Register callback error", e)
call.reject("Callback registration error: ${e.message}")
}
}
@PluginMethod
fun getContentCache(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {
try {
val latestCache = db.contentCacheDao().getLatest()
val result = JSObject()
if (latestCache != null) {
result.put("id", latestCache.id)
result.put("fetchedAt", latestCache.fetchedAt)
result.put("ttlSeconds", latestCache.ttlSeconds)
result.put("payload", String(latestCache.payload))
result.put("meta", latestCache.meta)
}
call.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to get content cache", e)
call.reject("Content cache retrieval failed: ${e.message}")
}
}
}
// Helper methods
private fun parseContentFetchConfig(configJson: JSObject): ContentFetchConfig {
return ContentFetchConfig(
enabled = configJson.getBoolean("enabled", true),
schedule = configJson.getString("schedule", "0 9 * * *"),
url = configJson.getString("url"),
timeout = configJson.getInt("timeout"),
retryAttempts = configJson.getInt("retryAttempts"),
retryDelay = configJson.getInt("retryDelay"),
callbacks = CallbackConfig(
apiService = configJson.getObject("callbacks")?.getString("apiService"),
database = configJson.getObject("callbacks")?.getString("database"),
reporting = configJson.getObject("callbacks")?.getString("reporting")
)
)
}
private fun parseUserNotificationConfig(configJson: JSObject): UserNotificationConfig {
return UserNotificationConfig(
enabled = configJson.getBoolean("enabled", true),
schedule = configJson.getString("schedule", "0 9 * * *"),
title = configJson.getString("title"),
body = configJson.getString("body"),
sound = configJson.getBoolean("sound"),
vibration = configJson.getBoolean("vibration"),
priority = configJson.getString("priority")
)
}
private fun calculateNextRunTime(schedule: String): Long {
// Simple implementation - for production, use proper cron parsing
val now = System.currentTimeMillis()
return now + (24 * 60 * 60 * 1000L) // Next day
}
}

144
android/android/plugin/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt

@ -0,0 +1,144 @@
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()
}
}

202
android/android/plugin/src/main/java/com/timesafari/dailynotification/FetchWorker.kt

@ -0,0 +1,202 @@
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
}
}
}

336
android/android/plugin/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt

@ -0,0 +1,336 @@
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)
}
}
}

3
android/app/capacitor.build.gradle

@ -7,7 +7,8 @@ android {
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
// Plugin development project - no Capacitor integration files needed
// apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {

42
android/plugin/build.gradle

@ -0,0 +1,42 @@
apply plugin: 'com.android.library'
android {
namespace "com.timesafari.dailynotification"
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
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"
annotationProcessor "androidx.room:room-compiler:2.6.1"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
}

1
android/settings.gradle

@ -1,4 +1,5 @@
include ':app'
include ':plugin'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')

Loading…
Cancel
Save