// mob_notify plugin — Android bridge (local notifications via AlarmManager +
// push registration via FirebaseMessaging).
//
// Extracted from mob-core's MobBridge notify_schedule / notify_cancel /
// notify_register_push (MobBridge.kt.eex ~1379-1444 pre-strip). DELIVERY
// stays in core/host: the host-package NotificationReceiver displays the
// scheduled notification + the tap routes through MainActivity.onNewIntent;
// MobFirebaseService handles incoming pushes + token refreshes. The shared
// state lives in io.mob.plugin.MobNotifyHub (GENERATED by mob_dev next to
// MobActivityAware) — the stable seam both sides can reference, since this
// bridge can't name host-package classes and the host can't name plugin
// packages without breaking plugin-less builds.
//
// HOST-CLASS NAME ASSUMPTION: the alarm intent targets
// "<applicationId>.NotificationReceiver" via setClassName — true for every
// mob_new-generated app (the Kotlin package IS the applicationId). A host
// with a divergent package must keep its receiver at that name.
//
// BOOT RE-ARM: AlarmManager alarms are wiped on reboot, so scheduled
// notifications silently vanish. notify_schedule persists each schedule to
// SharedPreferences (via MobNotifySchedules), and MobNotifyBootReceiver
// re-arms every still-future entry on ACTION_BOOT_COMPLETED. The shared arm +
// persist + read logic lives in the MobNotifySchedules object so the bridge
// (which has an Activity) and the boot receiver (which has only a Context)
// arm identically — every helper takes a Context.
//
// SINGLE-FILE LAYOUT: MobNotifySchedules and MobNotifyBootReceiver are declared
// in THIS file rather than separate .kt files because mob_dev copies exactly the
// one `android.bridge_kt` path into the host Kotlin sourceSet (and the plugin
// signer only signs that one path) — sibling files wouldn't be compiled or
// signed. Kotlin allows multiple top-level declarations per file, so all three
// ship together under the declared bridge_kt.
package io.mob.notify
import android.app.Activity
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 io.mob.plugin.MobNotifyHub
import java.lang.ref.WeakReference
import org.json.JSONArray
import org.json.JSONObject
object MobNotifyBridge : io.mob.plugin.MobActivityAware {
private var activityRef: WeakReference<Activity>? = null
@JvmStatic external fun nativeRegister()
// {:push_token, :android, token} — register_push's immediate token only;
// refreshed tokens flow through the host MobFirebaseService → core thunk.
@JvmStatic external fun nativeDeliverNotifyPushToken(pid: Long, token: String)
@JvmStatic fun register() = nativeRegister()
override fun setActivity(activity: Activity) {
activityRef = WeakReference(activity)
}
// Parse the opts JSON and delegate to MobNotifySchedules.schedule, which
// ensures the channel, arms an AlarmManager broadcast at trigger_at
// targeting the HOST's NotificationReceiver (exact-alarm guard + inexact
// fallback), AND persists the schedule to SharedPreferences so
// MobNotifyBootReceiver can re-arm it after a reboot (alarms are wiped on
// reboot). pid is accepted for zig-call parity; scheduling has no direct
// reply.
@JvmStatic
fun notify_schedule(pid: Long, optsJson: String) {
val activity = activityRef?.get() ?: return
try {
val opts = org.json.JSONObject(optsJson)
val id = opts.getString("id")
val title = opts.getString("title")
val body = opts.getString("body")
val triggerAt = opts.getLong("trigger_at") * 1000L // to ms
val data = opts.optJSONObject("data")?.toString() ?: "{}"
MobNotifySchedules.schedule(activity, id, triggerAt, title, body, data)
} catch (e: Exception) {
android.util.Log.e("MobNotify", "notify_schedule failed: ${e.message}")
}
}
@JvmStatic
fun notify_cancel(id: String) {
val activity = activityRef?.get() ?: return
MobNotifySchedules.cancel(activity, id)
}
// Registers the delivery target (the hub pid the host delivery paths key
// on) and resolves the FCM token: a refresh that arrived while no screen
// was registered is drained first; otherwise fetch fresh.
@JvmStatic
fun notify_register_push(pid: Long) {
MobNotifyHub.notifyPid = pid
MobNotifyHub.pendingToken?.let { token ->
MobNotifyHub.pendingToken = null
nativeDeliverNotifyPushToken(pid, token)
return
}
com.google.firebase.messaging.FirebaseMessaging.getInstance().token
.addOnCompleteListener { task ->
if (task.isSuccessful) nativeDeliverNotifyPushToken(pid, task.result)
}
}
}
// Shared scheduling helpers — everything takes a Context so the bridge (passing
// its Activity) and MobNotifyBootReceiver (passing the receiver Context) build
// the PendingIntent, arm the alarm, and persist/read schedules identically.
object MobNotifySchedules {
private const val PREFS = "mob_notify_schedules"
private const val KEY = "schedules"
// Build the broadcast PendingIntent the alarm fires — targets the HOST's
// NotificationReceiver (which displays + handles tap), keyed by id.hashCode()
// so notify_cancel / re-arm address the same alarm.
private fun pendingIntent(ctx: Context, id: String, title: String, body: String, data: String): PendingIntent {
val intent = Intent().apply {
setClassName(ctx, ctx.packageName + ".NotificationReceiver")
putExtra("title", title)
putExtra("body", body)
putExtra("id", id)
putExtra("data", data)
}
return PendingIntent.getBroadcast(
ctx, id.hashCode(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
private fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT >= 26) {
val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(
NotificationChannel(MobNotifyHub.CHANNEL_ID, "Notifications", NotificationManager.IMPORTANCE_DEFAULT))
}
}
// Arm the AlarmManager alarm with the exact-alarm guard + inexact fallback.
// Android 12+ (API 31) gates EXACT alarms behind SCHEDULE_EXACT_ALARM special
// access; calling setExact* without it throws SecurityException, so guard on
// canScheduleExactAlarms() and fall back to an inexact (battery-batched) alarm.
private fun arm(ctx: Context, triggerAtMs: Long, pi: PendingIntent) {
val am = ctx.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val canExact = if (Build.VERSION.SDK_INT >= 31) am.canScheduleExactAlarms() else true
when {
canExact && Build.VERSION.SDK_INT >= 23 ->
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pi)
canExact ->
am.setExact(AlarmManager.RTC_WAKEUP, triggerAtMs, pi)
Build.VERSION.SDK_INT >= 23 ->
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pi)
else ->
am.set(AlarmManager.RTC_WAKEUP, triggerAtMs, pi)
}
}
// Ensure the channel, arm the alarm, and persist the schedule so a reboot
// can re-arm it. Used by notify_schedule and by the boot receiver's re-arm.
fun schedule(ctx: Context, id: String, triggerAtMs: Long, title: String, body: String, data: String) {
ensureChannel(ctx)
arm(ctx, triggerAtMs, pendingIntent(ctx, id, title, body, data))
persist(ctx, id, triggerAtMs, title, body, data)
}
// Cancel the alarm and drop the persisted entry.
fun cancel(ctx: Context, id: String) {
val intent = Intent().apply {
setClassName(ctx, ctx.packageName + ".NotificationReceiver")
}
val pi = PendingIntent.getBroadcast(
ctx, id.hashCode(), intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE)
pi?.let {
val am = ctx.getSystemService(Context.ALARM_SERVICE) as AlarmManager
am.cancel(it)
}
remove(ctx, id)
}
// Re-arm every persisted schedule still in the future; drop past-due ones.
// Called from MobNotifyBootReceiver on ACTION_BOOT_COMPLETED.
fun rearmAll(ctx: Context) {
val now = System.currentTimeMillis()
for (s in readAll(ctx)) {
val triggerAtMs = s.optLong("trigger_at_ms")
val id = s.optString("id")
if (triggerAtMs > now && id.isNotEmpty()) {
ensureChannel(ctx)
arm(ctx, triggerAtMs,
pendingIntent(ctx, id, s.optString("title"), s.optString("body"), s.optString("data", "{}")))
} else {
remove(ctx, id)
}
}
}
private fun prefs(ctx: Context) =
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun readAll(ctx: Context): List<JSONObject> {
val raw = prefs(ctx).getString(KEY, "[]") ?: "[]"
val out = ArrayList<JSONObject>()
try {
val arr = JSONArray(raw)
for (i in 0 until arr.length()) out.add(arr.getJSONObject(i))
} catch (e: Exception) {
android.util.Log.e("MobNotify", "readAll failed: ${e.message}")
}
return out
}
private fun writeAll(ctx: Context, entries: List<JSONObject>) {
val arr = JSONArray()
for (e in entries) arr.put(e)
prefs(ctx).edit().putString(KEY, arr.toString()).apply()
}
// Upsert one schedule by id (one JSON array in one prefs key).
private fun persist(ctx: Context, id: String, triggerAtMs: Long, title: String, body: String, data: String) {
val entries = readAll(ctx).filterNot { it.optString("id") == id }.toMutableList()
entries.add(JSONObject().apply {
put("id", id)
put("trigger_at_ms", triggerAtMs)
put("title", title)
put("body", body)
put("data", data)
})
writeAll(ctx, entries)
}
private fun remove(ctx: Context, id: String) {
writeAll(ctx, readAll(ctx).filterNot { it.optString("id") == id })
}
}
// Boot re-arm receiver. On ACTION_BOOT_COMPLETED, re-arms persisted schedules
// (AlarmManager alarms are wiped on reboot). The host AndroidManifest must
// declare this <receiver> with a BOOT_COMPLETED intent-filter — a plugin
// manifest can't contribute a <receiver> fragment (see host_requirements).
class MobNotifyBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
MobNotifySchedules.rearmAll(context)
}
}
}