iex
11/21/2025, 7:13 AMChrimaeon
11/21/2025, 7:58 AMiex
11/21/2025, 8:51 AMprivate val channel: NotificationChannelData = defaultChannelData()
class AndroidScheduledNotifications(
private val context: Context,
private val errorReporter: ErrorReporter,
private val log: Log,
private val scheduledNotificationsStorage: ScheduledNotificationsStorage,
private val channelsManager: AndroidNotificationsChannelsManager,
private val proxyLogger: ProxyLogger,
) : ScheduledNotifications {
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
@RequiresPermission(Manifest.permission.SCHEDULE_EXACT_ALARM)
override suspend fun sendNotification(notification: ScheduledNotification) {
channelsManager.createChannel(channel)
val intent = Intent(context, NotificationReceiver::class.java).apply {
putExtra("NOTIFICATION_ID", notification.id)
putExtra("TITLE", notification.title)
putExtra("BODY", notification.content)
putExtra("DEEP_LINK", notification.deepLink?.link)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
notification.id.hashCode(), // Unique request code
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
if (alarmManager != null) {
// save id to be able to cancel
scheduledNotificationsStorage.saveId(notification.id).fold(
ifLeft = { errorReporter.report("Error saving notification id: $it") },
ifRight = { log.t(CT.not) { "Saved notification id to storage: $it" } }
)
log.t(CT.not) { "Scheduling notification: $notification, with intent: $pendingIntent " }
// alarmManager.setExactAndAllowWhileIdle(
// this one is less exact (~10 mins) but doesn't require new permissions
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
notification.time.seconds.toMillis().value,
pendingIntent
)
} else {
errorReporter.report("Unexpected: alarm manager is null in sendNotification: can't schedule local notifications")
}
}
override suspend fun cancelAllScheduledNotifications(): Either<Throwable, Unit> =
scheduledNotificationsStorage.ids().fold(
ifLeft = { Left(it) },
ifRight = { ids ->
log.d(CT.not) { "Will cancel all scheduled notifications: $ids" }
ids.forEach { id ->
cancelNotification(id)
}
val msg = "Cancelled all scheduled notifications: $ids"
log.t(CT.not) { msg }
proxyLogger.send(msg)
scheduledNotificationsStorage.clearAll().fold(
ifLeft = { errorReporter.report("Error clearing notification ids: $it") },
ifRight = { log.t(CT.not) { "Cleared notification ids from storage" } }
)
Right(Unit)
}
)
private fun cancelNotification(id: String) {
val intent = Intent(context, NotificationReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context,
id.hashCode(),
intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
if (alarmManager == null) {
errorReporter.report("Unexpected: alarm manager is null in cancelNotification: can't cancel local notifications")
}
pendingIntent?.let {
alarmManager?.cancel(it)
it.cancel()
}
}
}
class NotificationReceiver : BroadcastReceiver() {
@OptIn(ExperimentalTime::class)
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
override fun onReceive(context: Context, intent: Intent) {
val deps = deps()
val log = deps?.log
val errorReporter = deps?.errorReporter
log?.d(CT.not) { "Broadcast receiver: start: intent: $intent" }
val notificationId = intent.getStringExtra("NOTIFICATION_ID") ?: return
val title = intent.getStringExtra("TITLE") ?: ""
val body = intent.getStringExtra("BODY") ?: ""
val deepLink = intent.getStringExtra("DEEP_LINK")
log?.d(CT.not) { "Broadcast receiver: notificationId: $notificationId, title: $title" }
val notificationManager = NotificationManagerCompat.from(context)
val pendingIntent = deepLink?.let { pendingIntent(context, it, notificationId) }
val notification = NotificationCompat.Builder(context, channel.id)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.drawable.ic_stat_name)
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.coach))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(body)
)
.build()
try {
log?.d(CT.not) { "Broadcast receiver: sending notification: $notification -- title: $title, body: $body" }
notificationManager.notify(notificationId.hashCode(), notification)
} catch (e: SecurityException) {
errorReporter?.report("$e sending notification in broadcast receiver")
}
}
private fun deps(): BroadcastReceiverDeps? =
try {
ensureKoinInitialized()
val koin = KoinPlatform.getKoin()
BroadcastReceiverDeps(
log = koin.get(),
errorReporter = koin.get(),
proxyLogger = koin.get(),
)
} catch (t: Throwable) {
android.util.Log.e(
"notifications",
"Error retrieving koin dependency in broadcast receiver: $t"
)
null
}
private fun pendingIntent(
context: Context,
deepLink: String,
notificationId: String,
): PendingIntent {
println("Creating PendingIntent with deepLink: $deepLink")
val intent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
data = deepLink.toUri()
}
return PendingIntent.getActivity(
context,
// id here might not be needed (could be just 0? - we use id in getBroadcast and to send the notification), but doesn't hurt
notificationId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
}
private data class BroadcastReceiverDeps(
val log: Log,
val errorReporter: ErrorReporter,
val proxyLogger: ProxyLogger,
)
scheduling:
scheduledNotifications.cancelAllScheduledNotifications()
// the hour of day where we want to send the notification (minutes, seconds assumed 0)
val notificationHour = 9
val userTimezone = TimeZone.currentSystemDefault()
val now: Instant = Clock.System.now()
val nowLocal: LocalDateTime = now.toLocalDateTime(userTimezone)
val currentDate: LocalDate = nowLocal.date
// check whether we're before of after notification time in the day,
// to schedule "for today" or "for tomorrow"
val todayAt9AM = LocalDateTime(
date = currentDate,
time = LocalTime(hour = notificationHour, minute = 0, second = 0)
).toInstant(userTimezone)
val startOffset = if (now >= todayAt9AM) 1 else 0
// for remote debugging - remove when notifications working reliably
val scheduledTimes = mutableListOf<Pair<Int, Long>>() // index to epochSeconds
notifications.forEachIndexed { index, payload ->
val targetDate = currentDate.plus(index + startOffset, DateTimeUnit.DAY)
val scheduledTime = LocalDateTime(
date = targetDate,
time = LocalTime(hour = notificationHour, minute = 0, second = 0)
).toInstant(userTimezone)
scheduledTimes.add(index to scheduledTime.epochSeconds)
scheduledNotifications.sendNotification(
ScheduledNotification(
id = Uuid.random().toString(),
title = payload.title,
content = payload.text,
image = null,
deepLink = DeepLinkData(link = "${serverConfig.baseUrl}/home/${spaceId.value}/${agentId.value}"),
time = EpochSeconds(Seconds(scheduledTime.epochSeconds))
)
)
}
proxyLogger.send(
"Scheduled ${notifications.size} notifications: ${scheduledTimes.joinToString { "(${it.first}:${it.second})" }}"
)Chrimaeon
11/21/2025, 11:56 AM