// 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 // ".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? = 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 { val raw = prefs(ctx).getString(KEY, "[]") ?: "[]" val out = ArrayList() 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) { 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 with a BOOT_COMPLETED intent-filter — a plugin // manifest can't contribute a fragment (see host_requirements). class MobNotifyBootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == Intent.ACTION_BOOT_COMPLETED) { MobNotifySchedules.rearmAll(context) } } }