package com.anonymous.bulwarkmobile import android.app.ActivityManager import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.media.AudioAttributes import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PowerManager import android.util.Log import androidx.core.app.NotificationCompat import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter import com.facebook.react.bridge.Arguments import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage class BulwarkMessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { val params = Arguments.createMap().apply { putString("token", token) } BulwarkFcmModule.emit("fcm:newToken", params) } override fun onMessageReceived(message: RemoteMessage) { ensureChannel(this) val data = message.data // Contact and calendar changes become syncs of the Android accounts // they feed (device sync, #34); only mail needs the push task. val mail = DeviceSyncPushRouter.route(applicationContext, data) startTaskUnlessInForeground(applicationContext, data, mail) val params = Arguments.createMap().apply { val dataMap = Arguments.createMap() for ((k, v) in data) dataMap.putString(k, v) putMap("data", dataMap) } BulwarkFcmModule.emit("fcm:message", params) } companion object { // Plays the Involved sound; see channelId(). const val CHANNEL_ID = "bulwark_mail_involved" // The first mail channel, with the system's default sound. const val LEGACY_CHANNEL_ID = "bulwark_mail" private const val TAG = "BulwarkPush" private const val START_WAKE_LOCK_MS = 10_000L /** * Runs the push task for a mail push unless the app's screen is * resumed: then the user is looking at the app, which syncs over its * own event stream, and HeadlessJsTaskContext would refuse the task. * Shared with BulwarkUnifiedPushService, so a push arriving over * either transport is dispatched the same way. * * The process importance is no measure of that (#94): an app paused * under a translucent screen, or kept important by anything bound to * it, counts as foreground, and its pushes ended without a * notification. */ fun startTaskUnlessInForeground(context: Context, data: Map, mail: Boolean) { val foreground = MainActivity.isInForeground Log.i(TAG, "Push: mail=$mail foreground=$foreground importance=${processImportance()}") if (mail && !foreground) { // The task opens its own connection rather than one left // over from the foreground, which may be dead (#94). HttpConnections.dropIdle() startHeadlessTask(context, data) } } /** For the log only. */ private fun processImportance(): Int { val info = ActivityManager.RunningAppProcessInfo() ActivityManager.getMyMemoryState(info) return info.importance } fun startHeadlessTask(context: Context, data: Map) { val intent = Intent(context, BulwarkPushTaskService::class.java) val bundle = Bundle().apply { for ((k, v) in data) putString(k, v) } intent.putExtras(bundle) // Keep the device awake until the service holds React Native's // wake lock; BulwarkPushTaskService releases this one then. Not // HeadlessJsTaskService.acquireWakeLockNow: it is not // thread-safe, and FCM calls this on a worker thread while the // service takes the same static lock on the main thread, so both // could create a lock and the overwritten one stayed held. startWakeLock = context.getSystemService(PowerManager::class.java) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "bulwark:push-start") .apply { setReferenceCounted(false) acquire(START_WAKE_LOCK_MS) } try { context.startService(intent) } catch (e: IllegalStateException) { // Android 8+ refuses to start a service from the background // unless the app is temporarily allowlisted. A high-priority // FCM message does that; a downgraded one or a UnifiedPush // message may not (BackgroundServiceStartNotAllowedException // on 12+), and uncaught this crashed the app on every such // push. Run the same task from a job instead. releaseStartWakeLock() Log.w(TAG, "Push service start refused in the background, using a job: ${e.message}") BulwarkPushJobService.schedule(context, data) } } @Volatile private var startWakeLock: PowerManager.WakeLock? = null /** Drops the wake lock taken in [startHeadlessTask]; it also times out on its own. */ fun releaseStartWakeLock() { startWakeLock?.release() startWakeLock = null } /** * The channel new-mail notifications go to. A channel's sound can't * change after it is created, so the Involved sound (the webmail's * default, lib/notification-sound.ts) needs a channel of its own; the * old one stays as the user left it. Someone who turned mail * notifications off in the old channel keeps them off: their * notifications keep going there. */ fun channelId(context: Context): String { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return CHANNEL_ID val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val legacy = manager.getNotificationChannel(LEGACY_CHANNEL_ID) return if (legacy != null && legacy.importance == NotificationManager.IMPORTANCE_NONE) { LEGACY_CHANNEL_ID } else { CHANNEL_ID } } /** The Involved sound (res/raw, kept from the shrinker by res/raw/keep.xml). */ fun soundUri(context: Context): Uri = Uri.parse("android.resource://${context.packageName}/raw/involved_notification") fun ensureChannel(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (channelId(context) != CHANNEL_ID) return if (manager.getNotificationChannel(CHANNEL_ID) != null) return val sound = soundUri(context) val attributes = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build() val channel = NotificationChannel( CHANNEL_ID, "Mail notifications", NotificationManager.IMPORTANCE_HIGH, ).apply { description = "Incoming email alerts" enableVibration(true) setSound(sound, attributes) } manager.createNotificationChannel(channel) } } }