mobiler 0.64.0

Build mobile apps in Rust — one core, native UI on Android, iOS, and the web (CLI)
package {{PACKAGE}}

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import org.json.JSONObject
import java.util.concurrent.atomic.AtomicInteger

// FCM entry point — runs in the app process even when no UI is alive (FCM starts it). Statically
// declared in the manifest by the plugin (a <service> with the MESSAGING_EVENT intent-filter).
//   onNewToken       → push a {"type":"token_refresh","token":…} event so the app re-POSTs to its backend.
//   onMessageReceived→ build a tray notification (data messages don't auto-display) whose tap opens the
//                      app carrying the payload (→ an "opened" event), and — only if the app is in the
//                      foreground — emit the payload as "received" right away.
// Send DATA messages from your backend so the app controls display. (Notification messages sent while
// the app is backgrounded are posted by FCM itself and never reach this service; PushPlugin still
// recognises their tap as "opened".)
private const val CHANNEL_ID = "mobiler_push"

// Unique per notification: both the notification id and the PendingIntent request code. A shared
// request code + FLAG_UPDATE_CURRENT would make every notification's tap deliver the LATEST payload.
private val nextId = AtomicInteger((System.currentTimeMillis() and 0xFFFFFF).toInt())

class PushMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        PushBus.emit("""{"type":"token_refresh","token":"$token"}""")
    }

    override fun onMessageReceived(message: RemoteMessage) {
        // Merge the data map (+ any notification title/body) into one JSON payload for the app.
        val payload = JSONObject()
        for ((k, v) in message.data) payload.put(k, v)
        message.notification?.let { n ->
            n.title?.let { payload.put("title", it) }
            n.body?.let { payload.put("body", it) }
        }
        val json = payload.toString()
        val id = nextId.incrementAndGet()

        // Post a tray notification so the user sees something even for a data-only message. Its tap
        // brings the app forward with the payload attached; PushPlugin turns that into "opened".
        ensureChannel(this)
        val title = message.notification?.title ?: message.data["title"] ?: "Notification"
        val body = message.notification?.body ?: message.data["body"] ?: ""
        val builder = NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(smallIcon(this))
            .setContentTitle(title)
            .setContentText(body)
            .setAutoCancel(true)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
        packageManager.getLaunchIntentForPackage(packageName)?.let { launch ->
            launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
            launch.putExtra(PushOpens.EXTRA_PAYLOAD, json)
            builder.setContentIntent(
                PendingIntent.getActivity(this, id, launch, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
            )
        }
        runCatching { NotificationManagerCompat.from(this).notify(id, builder.build()) }

        // "received" is live-only and foreground-only (matching iOS willPresent): a push that arrives
        // while the app is backgrounded or dead is represented by the tray notification alone.
        if (MobilerActivity.current?.get() != null) PushBus.emitReceived(json)
    }
}

// Tray icon: the app's `mobiler_push_icon` drawable (a white-on-transparent status-bar icon) when it
// exists, else a system fallback.
private fun smallIcon(context: Context): Int =
    context.resources.getIdentifier("mobiler_push_icon", "drawable", context.packageName)
        .takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info

private fun ensureChannel(context: Context) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        // Channel name shown in system Settings → Notifications: the app's `mobiler_push_channel_name`
        // string resource when it exists, else "Notifications". Re-creating an existing channel only
        // updates its name, so a renamed resource takes effect on the next push.
        val nameRes = context.resources.getIdentifier("mobiler_push_channel_name", "string", context.packageName)
        val name = if (nameRes != 0) context.getString(nameRes) else "Notifications"
        mgr.createNotificationChannel(NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_HIGH))
    }
}