Hi, any ideas why local notifications are often no...
# android
i
Hi, any ideas why local notifications are often not showing up (at all)? • scheduled daily at ~9:00am • cancelled and re-scheduled often (on app's on resume): this is temporary behavior which I'll improve, but technically, it should work? I added remote logging and noticed that the broadcast receiver is not called (though it's also technically possible that the error reporter is not available/doesn't work) Thanks!
not kotlin but kotlin colored 4
🧵 2
c
1. please don’t spam the main Thread with Long code snippets. 2. This is a workspace for the Kotlin Programming Language. Not an Android help Forum. 3. https://kotlinlang.org/docs/slack-code-of-conduct.html#how-to-behave
i
1. Code now here. 2. Curious, what kind of Android specific questions that are not Android help and generic Kotlin is this for? like a DSL or something?
Copy code
private 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:
Copy code
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})" }}"
)
c
Check the Channel and see which posts are not not kotlin but kotlin colored 😉