mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
chore: add Android release CI and sync local work
CI: - .github/workflows/release-android.yml builds APK on release publish and via workflow_dispatch, attaches it to the release. - android/app/build.gradle: optional release signingConfig driven by Gradle properties (ANDROID_KEYSTORE_BASE64 secret etc.); falls back to the debug keystore when no secrets are set. - .gitignore: untrack /android so the runner can build directly; ignore build outputs (build/, .cxx/), Metro/Expo dev logs, scratch. Track android/ sources including BulwarkFcmPackage.kt and google-services.json (Firebase Android keys are public per Google's docs and intended to ship in the APK). JS sync: push notifications + background task, vacation responder (api + store), contact activity component, search utilities, settings polish, calendar/contacts/email store and screen tweaks. Tooling: scripts/poll-reload.mjs — Windows polling-watcher that hits Metro /reload on file change (workaround for unreliable fs.watch without Watchman).
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
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.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")
|
||||
|
||||
// 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,
|
||||
)
|
||||
promise.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getInitialNotification(promise: Promise) {
|
||||
val payload = NotificationTapStore.consume()
|
||||
promise.resolve(payload?.toMap())
|
||||
}
|
||||
|
||||
private fun postNotification(
|
||||
notificationId: String,
|
||||
title: String,
|
||||
body: String,
|
||||
largeIcon: Bitmap,
|
||||
colorHex: String,
|
||||
emailId: String?,
|
||||
threadId: String?,
|
||||
subject: 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)
|
||||
}
|
||||
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(android.R.drawable.ic_dialog_email)
|
||||
.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)
|
||||
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
||||
}
|
||||
|
||||
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")
|
||||
conn.inputStream.use { BitmapFactory.decodeStream(it) }
|
||||
} 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 {
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class BulwarkFcmPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(BulwarkFcmModule(reactContext))
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||
emptyList()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
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
|
||||
|
||||
// Hand off to JS via a headless task only when the app isn't already
|
||||
// running in the foreground — HeadlessJsTaskContext throws if started
|
||||
// while foreground. When the app is open, the main JS instance already
|
||||
// receives JMAP push directly, so the fcm:message event below is
|
||||
// sufficient for it to refresh state.
|
||||
if (!isAppInForeground()) {
|
||||
startHeadlessTask(data)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun isAppInForeground(): Boolean {
|
||||
val info = ActivityManager.RunningAppProcessInfo()
|
||||
ActivityManager.getMyMemoryState(info)
|
||||
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
|
||||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
|
||||
}
|
||||
|
||||
private fun startHeadlessTask(data: Map<String, String>) {
|
||||
val intent = Intent(applicationContext, BulwarkPushTaskService::class.java)
|
||||
val bundle = Bundle().apply {
|
||||
for ((k, v) in data) putString(k, v)
|
||||
}
|
||||
intent.putExtras(bundle)
|
||||
applicationContext.startService(intent)
|
||||
HeadlessJsTaskService.acquireWakeLockNow(applicationContext)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "bulwark_mail"
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Mail notifications",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "Incoming email alerts"
|
||||
enableVibration(true)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
|
||||
class BulwarkPushTaskService : HeadlessJsTaskService() {
|
||||
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
|
||||
val extras = intent?.extras ?: return null
|
||||
return HeadlessJsTaskConfig(
|
||||
"BulwarkPushTask",
|
||||
Arguments.fromBundle(extras),
|
||||
30000L,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||
|
||||
import expo.modules.ReactActivityDelegateWrapper
|
||||
|
||||
class MainActivity : ReactActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Set the theme to AppTheme BEFORE onCreate to support
|
||||
// coloring the background, status bar, and navigation bar.
|
||||
// This is required for expo-splash-screen.
|
||||
setTheme(R.style.AppTheme);
|
||||
super.onCreate(null)
|
||||
NotificationTapStore.captureFromIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
|
||||
BulwarkFcmModule.emit("fcm:notificationTap", payload.toMap())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||
* rendering of the component.
|
||||
*/
|
||||
override fun getMainComponentName(): String = "main"
|
||||
|
||||
/**
|
||||
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
|
||||
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
|
||||
*/
|
||||
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
||||
return ReactActivityDelegateWrapper(
|
||||
this,
|
||||
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
|
||||
object : DefaultReactActivityDelegate(
|
||||
this,
|
||||
mainComponentName,
|
||||
fabricEnabled
|
||||
){})
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the back button behavior with Android S
|
||||
* where moving root activities to background instead of finishing activities.
|
||||
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
|
||||
*/
|
||||
override fun invokeDefaultOnBackPressed() {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||
if (!moveTaskToBack(false)) {
|
||||
// For non-root activities, use the default implementation to finish them.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use the default back button implementation on Android S
|
||||
// because it's doing more than [Activity.moveTaskToBack] in fact.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.Application
|
||||
import android.content.res.Configuration
|
||||
|
||||
import com.facebook.react.PackageList
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.ReactNativeHost
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.common.ReleaseLevel
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
|
||||
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||
|
||||
import expo.modules.ApplicationLifecycleDispatcher
|
||||
import expo.modules.ReactNativeHostWrapper
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
|
||||
this,
|
||||
object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): MutableList<ReactPackage> {
|
||||
val packages: MutableList<ReactPackage> = PackageList(this).packages
|
||||
packages.add(BulwarkFcmPackage())
|
||||
return packages
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
|
||||
|
||||
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
|
||||
|
||||
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
||||
}
|
||||
)
|
||||
|
||||
override val reactHost: ReactHost
|
||||
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DefaultNewArchitectureEntryPoint.releaseLevel = try {
|
||||
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
ReleaseLevel.STABLE
|
||||
}
|
||||
loadReactNative(this)
|
||||
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
|
||||
// Process-wide holder for the most recent notification tap. MainActivity fills
|
||||
// this on onCreate / onNewIntent; JS drains it via BulwarkFcm.getInitialNotification
|
||||
// or reacts to the live "fcm:notificationTap" event.
|
||||
object NotificationTapStore {
|
||||
@Volatile
|
||||
private var pending: TapPayload? = null
|
||||
|
||||
fun consume(): TapPayload? {
|
||||
val value = pending
|
||||
pending = null
|
||||
return value
|
||||
}
|
||||
|
||||
fun captureFromIntent(intent: Intent?): TapPayload? {
|
||||
val extras = intent?.extras ?: return null
|
||||
val emailId = extras.getString(EXTRA_EMAIL_ID) ?: return null
|
||||
val threadId = extras.getString(EXTRA_THREAD_ID) ?: return null
|
||||
val subject = extras.getString(EXTRA_SUBJECT)
|
||||
val payload = TapPayload(emailId, threadId, subject)
|
||||
pending = payload
|
||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||
extras.remove(EXTRA_EMAIL_ID)
|
||||
extras.remove(EXTRA_THREAD_ID)
|
||||
extras.remove(EXTRA_SUBJECT)
|
||||
return payload
|
||||
}
|
||||
|
||||
data class TapPayload(
|
||||
val emailId: String,
|
||||
val threadId: String,
|
||||
val subject: String?,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
putString("threadId", threadId)
|
||||
if (subject != null) putString("subject", subject)
|
||||
}
|
||||
}
|
||||
|
||||
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
|
||||
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
}
|
||||
Reference in New Issue
Block a user