package com.anonymous.bulwarkmobile import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Typeface import androidx.core.app.NotificationCompat import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter import com.google.firebase.messaging.FirebaseMessaging import java.io.ByteArrayOutputStream import java.net.HttpURLConnection import java.net.URL import kotlin.concurrent.thread class BulwarkFcmModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { init { synchronized(BulwarkFcmModule::class.java) { currentInstance = this } BulwarkMessagingService.ensureChannel(reactContext) } override fun getName(): String = "BulwarkFcm" @ReactMethod fun getToken(promise: Promise) { FirebaseMessaging.getInstance().token .addOnSuccessListener { token -> promise.resolve(token) } .addOnFailureListener { err -> promise.reject("fcm_token_failed", err) } } @ReactMethod fun deleteToken(promise: Promise) { FirebaseMessaging.getInstance().deleteToken() .addOnSuccessListener { promise.resolve(null) } .addOnFailureListener { err -> promise.reject("fcm_delete_failed", err) } } @ReactMethod fun showNotification(options: ReadableMap, promise: Promise) { val notificationId = options.getString("notificationId") if (notificationId.isNullOrBlank()) { promise.reject("bad_args", "notificationId is required") return } val title = options.getString("title") ?: "New mail" val body = options.getString("body") ?: "" val initials = options.getString("initials") ?: "?" val bgColorHex = options.getString("bgColorHex") ?: "#2563eb" val iconUrl = options.takeIf { it.hasKey("iconUrl") }?.getString("iconUrl") val emailId = options.getString("emailId") val threadId = options.getString("threadId") val subject = options.takeIf { it.hasKey("subject") }?.getString("subject") val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId") val jmapAccountId = options.takeIf { it.hasKey("jmapAccountId") }?.getString("jmapAccountId") val groupKey = options.takeIf { it.hasKey("groupKey") }?.getString("groupKey") ?: accountId?.let { "bulwark-mail:$it" } val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle") ?: accountId ?: "Bulwark Mail" // Bitmap fetch + draw off the bridge thread so the caller doesn't // block waiting for the favicon request. thread(name = "bulwark-notification") { val largeIcon = iconUrl?.let { fetchBitmap(it) } ?: makeLetterAvatar(initials, bgColorHex) postNotification( notificationId, title, body, largeIcon, bgColorHex, emailId, threadId, subject, accountId, jmapAccountId, groupKey, ) if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId) promise.resolve(null) } } @ReactMethod fun getInitialNotification(promise: Promise) { val payload = NotificationTapStore.consume() promise.resolve(payload?.toMap()) } @ReactMethod fun getInitialShare(promise: Promise) { val payload = ShareIntentStore.consume() promise.resolve(payload?.toMap()) } private fun postNotification( notificationId: String, title: String, body: String, largeIcon: Bitmap, colorHex: String, emailId: String?, threadId: String?, subject: String?, accountId: String?, jmapAccountId: String?, groupKey: String?, ) { val ctx = reactApplicationContext val intent = Intent(ctx, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId) if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject) if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId) } val pending = PendingIntent.getActivity( ctx, notificationId.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setLargeIcon(largeIcon) .setContentTitle(title) .setContentText(body) .setStyle(NotificationCompat.BigTextStyle().bigText(body)) .setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb"))) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH) .setContentIntent(pending) if (groupKey != null) { builder.setGroup(groupKey) builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) } val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.notify(notificationId, notificationId.hashCode(), builder.build()) } // One summary per account group so several deliveries collapse into a // single "N new messages" entry (Android renders the "+N more" itself // from the children). Tapping the summary opens the app on the inbox; // the per-message children carry the deep link. private fun postGroupSummary(groupKey: String, groupTitle: String, colorHex: String, accountId: String?) { val ctx = reactApplicationContext val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val children = manager.activeNotifications.filter { it.groupKey?.endsWith(groupKey) == true && (it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0 } val count = maxOf(children.size, 1) val inbox = NotificationCompat.InboxStyle() children.sortedByDescending { it.postTime }.take(5).forEach { sbn -> val extras = sbn.notification.extras val line = listOfNotNull( extras.getCharSequence(android.app.Notification.EXTRA_TITLE), extras.getCharSequence(android.app.Notification.EXTRA_TEXT), ).joinToString(" ") if (line.isNotBlank()) inbox.addLine(line) } inbox.setBigContentTitle(groupTitle) inbox.setSummaryText(if (count == 1) "1 new message" else "$count new messages") val intent = Intent(ctx, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) } val pending = PendingIntent.getActivity( ctx, groupKey.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(groupTitle) .setContentText(if (count == 1) "1 new message" else "$count new messages") .setStyle(inbox) .setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb"))) .setGroup(groupKey) .setGroupSummary(true) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setAutoCancel(true) .setContentIntent(pending) .build() manager.notify(groupKey, groupKey.hashCode(), summary) } private fun makeLetterAvatar(initials: String, bgHex: String): Bitmap { val size = 192 val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = parseColor(bgHex, fallback = Color.parseColor("#2563eb")) } canvas.drawCircle(size / 2f, size / 2f, size / 2f, bgPaint) val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE textSize = size * 0.44f textAlign = Paint.Align.CENTER typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) } val baseline = size / 2f - (textPaint.descent() + textPaint.ascent()) / 2f canvas.drawText(initials.take(2).uppercase(), size / 2f, baseline, textPaint) return bitmap } private fun fetchBitmap(url: String): Bitmap? = try { val conn = URL(url).openConnection() as HttpURLConnection conn.connectTimeout = 3000 conn.readTimeout = 3000 conn.instanceFollowRedirects = true conn.setRequestProperty("User-Agent", "bulwark-mobile") // Cap the response to MAX_FAVICON_BYTES so a hostile / misconfigured // server can't OOM the headless task with a multi-megabyte image. // We also sample down to ~256x256 since it'll only render at 192px. val stream = conn.inputStream val bytes = stream.use { input -> val limit = MAX_FAVICON_BYTES val buf = ByteArrayOutputStream() val chunk = ByteArray(8 * 1024) var total = 0 while (true) { val n = input.read(chunk) if (n <= 0) break total += n if (total > limit) return@use null buf.write(chunk, 0, n) } buf.toByteArray() } ?: return null val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) var sample = 1 while ((opts.outWidth / sample) > 384 || (opts.outHeight / sample) > 384) { sample *= 2 } val decodeOpts = BitmapFactory.Options().apply { inSampleSize = sample } BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts) } catch (_: Exception) { null } private fun parseColor(hex: String, fallback: Int): Int = try { Color.parseColor(hex) } catch (_: IllegalArgumentException) { fallback } // NativeEventEmitter required no-ops. @ReactMethod fun addListener(eventName: String) {} @ReactMethod fun removeListeners(count: Int) {} companion object { // 1 MiB is plenty for a favicon; anything bigger is a sign of trouble. private const val MAX_FAVICON_BYTES = 1 * 1024 * 1024 @Volatile private var currentInstance: BulwarkFcmModule? = null fun emit(eventName: String, params: WritableMap?) { val module = currentInstance ?: return val ctx = module.reactApplicationContext if (!ctx.hasActiveReactInstance()) return ctx.getJSModule(RCTDeviceEventEmitter::class.java).emit(eventName, params) } } }