Skip to main content

priv/native/android/MobNotifyBridge.kt

// 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)
        }
    }
}