Compare commits

..

9 Commits

Author SHA1 Message Date
GitHub Actions
1c2904898f chore: bump version to 4.11.91 [skip ci] 2026-04-28 05:46:30 +00:00
0fe41a55c1 feat: add custom notification sound for message alerts in MatrixSyncService
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m56s
Build / create-release (push) Successful in 14s
2026-04-28 15:46:20 +10:00
af2f7dbcbc feat: enhance MatrixSyncService with user profile handling and image notifications 2026-04-26 18:54:58 +10:00
GitHub Actions
0a4a9aa636 chore: bump version to 4.11.90 [skip ci] 2026-04-26 04:32:30 +00:00
746d0aa384 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m42s
Build / create-release (push) Successful in 14s
2026-04-26 14:32:22 +10:00
c263b681d4 feat: add ShareRoomPicker component for selecting rooms to share Android content
- Implemented ShareRoomPicker component to allow users to pick a room for sharing content received from Android intents.
- Integrated search functionality to filter rooms and directs.
- Added virtualized list for efficient rendering of room options.
- Created utility functions for handling Android share payloads, including fetching and clearing pending shares.

Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:32:19 +10:00
GitHub Actions
6f583d1227 chore: bump version to 4.11.89 [skip ci] 2026-04-26 00:56:21 +00:00
6df60c8348 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m48s
Build / create-release (push) Successful in 14s
2026-04-26 10:56:12 +10:00
79d46857f9 feat: integrate UnifiedPush for background synchronization and event handling
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 10:56:10 +10:00
22 changed files with 1702 additions and 128 deletions

View File

@@ -27,8 +27,8 @@ android {
applicationId "com.paarrot.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode 1777176952
versionName "2026-04-26.041552.151"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -65,6 +65,7 @@ dependencies {
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'org.unifiedpush.android:connector:3.3.2'
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

View File

@@ -22,6 +22,24 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<provider
@@ -40,6 +58,14 @@
android:exported="false"
android:stopWithTask="false" />
<service
android:name=".UnifiedPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".BootReceiver"
android:exported="true">
@@ -47,15 +73,6 @@
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".PushPingReceiver"
android:exported="true">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE" />
<action android:name="com.paarrot.app.ACTION_PUSH_PING" />
</intent-filter>
</receiver>
</application>
<!-- Permissions -->

View File

@@ -5,13 +5,17 @@ import android.content.Context
import android.content.Intent
/**
* Triggers a one-shot sync fetch after boot if credentials exist.
* Re-registers UnifiedPush after boot when credentials still exist.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
MatrixSyncService.requestSyncFetch(context, "boot_completed")
val prefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
prefs.getString(MatrixSyncService.EXTRA_HOMESERVER, null) ?: return
prefs.getString(MatrixSyncService.EXTRA_TOKEN, null) ?: return
UnifiedPushManager.register(context, activity = null)
}
}

View File

@@ -7,6 +7,7 @@ public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(SyncServicePlugin.class);
registerPlugin(ShareHandlerPlugin.class);
super.onCreate(savedInstanceState);
}
}

View File

@@ -5,10 +5,14 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.media.AudioAttributes
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -23,10 +27,8 @@ import java.net.URL
import java.net.URLEncoder
/**
* ForegroundService that performs a short, one-shot Matrix /sync fetch.
*
* This service is wake-triggered by push pings (UnifiedPush/FCM bridge) and
* then fetches message content from Matrix before posting local notifications.
* ForegroundService that performs a short, one-shot Matrix /sync fetch after
* a wake ping from UnifiedPush or a manual diagnostic trigger.
*/
class MatrixSyncService : Service() {
@@ -36,6 +38,14 @@ class MatrixSyncService : Service() {
/** Event IDs already shown as notifications this session — prevents duplicates on restart. */
private val shownEventIds = HashSet<String>(64)
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
private val displayNameCache = HashMap<String, String>(16)
/** In-memory cache of MXID → avatar bitmap (null = no avatar or fetch failed). */
private val avatarCache = HashMap<String, Bitmap?>(16)
private data class UserProfile(val displayName: String, val avatar: Bitmap?)
override fun onBind(intent: Intent?) = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -53,7 +63,7 @@ class MatrixSyncService : Service() {
return START_NOT_STICKY
}
val triggerReason = intent?.getStringExtra(EXTRA_TRIGGER_REASON) ?: "unknown"
val triggerReason = intent?.getStringExtra(EXTRA_TRIGGER_REASON) ?: MODE_ONE_SHOT
startForeground(NOTIF_ID_STATUS, buildStatusNotification())
serviceScope.launch {
@@ -142,6 +152,9 @@ class MatrixSyncService : Service() {
}
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
val token = prefs.getString(EXTRA_TOKEN, null) ?: return
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
ensureMessageChannel(nm)
@@ -161,16 +174,119 @@ class MatrixSyncService : Service() {
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
val content = event.optJSONObject("content") ?: continue
val body = content.optString("body").takeIf { it.isNotBlank() } ?: continue
val msgtype = content.optString("msgtype")
val rawBody = content.optString("body")
val body = when (msgtype) {
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo"
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video"
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker"
else -> rawBody.takeIf { it.isNotBlank() } ?: continue
}
val sender = event.optString("sender")
val senderName = sender.substringAfter("@").substringBefore(":")
val profile = resolveProfile(sender, homeserver, token)
showMessageNotification(nm, senderName, body)
// For unencrypted image/sticker messages, try to download a preview bitmap.
// Encrypted messages have a `file` object instead of a top-level `url`.
val inlineImage: Bitmap? = if (
(msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file")
) {
content.optString("url").takeIf { it.startsWith("mxc://") }
?.let { mxcToDownloadUrl(it, homeserver) }
?.let { downloadBitmap(it, token) }
} else null
showMessageNotification(nm, profile.displayName, body, profile.avatar, inlineImage)
}
}
}
private fun showMessageNotification(nm: NotificationManager, sender: String, body: String) {
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
val cachedName = displayNameCache[mxid]
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
val fallback = mxid.substringAfter("@").substringBefore(":")
return try {
val encodedId = URLEncoder.encode(mxid, "UTF-8")
val url = "$homeserver/_matrix/client/v3/profile/$encodedId"
val conn = URL(url).openConnection() as HttpURLConnection
val (displayName, avatarMxc) = try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.setRequestProperty("Accept", "application/json")
conn.connectTimeout = 4_000
conn.readTimeout = 4_000
if (conn.responseCode == 200) {
val body = conn.inputStream.bufferedReader().readText()
val obj = JSONObject(body)
val name = obj.optString("displayname").takeIf { it.isNotBlank() } ?: fallback
val mxc = obj.optString("avatar_url").takeIf { it.startsWith("mxc://") }
Pair(name, mxc)
} else {
Pair(fallback, null)
}
} finally {
conn.disconnect()
}
displayNameCache[mxid] = displayName
val avatar = avatarMxc
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
?.let { downloadBitmap(it, token) }
avatarCache[mxid] = avatar
UserProfile(displayName, avatar)
} catch (e: Exception) {
Log.w(TAG, "Could not resolve profile for $mxid: ${e.message}")
displayNameCache[mxid] = fallback
avatarCache[mxid] = null
UserProfile(fallback, null)
}
}
private fun mxcToThumbnailUrl(mxcUrl: String, homeserver: String, size: Int): String? {
val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/')
if (slash < 0) return null
val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
}
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? {
val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/')
if (slash < 0) return null
val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/download/$serverName/$mediaId"
}
private fun downloadBitmap(urlString: String, token: String): Bitmap? {
return try {
val conn = URL(urlString).openConnection() as HttpURLConnection
try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.connectTimeout = 5_000
conn.readTimeout = 10_000
if (conn.responseCode == 200) BitmapFactory.decodeStream(conn.inputStream) else null
} finally {
conn.disconnect()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to download bitmap from $urlString: ${e.message}")
null
}
}
private fun showMessageNotification(
nm: NotificationManager,
sender: String,
body: String,
largeIcon: Bitmap? = null,
inlineImage: Bitmap? = null,
) {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
?: Intent(this, MainActivity::class.java)
val pi = PendingIntent.getActivity(
@@ -178,16 +294,25 @@ class MatrixSyncService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
val builder = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(sender)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
nm.notify(System.currentTimeMillis().toInt(), notification)
if (largeIcon != null) builder.setLargeIcon(largeIcon)
if (inlineImage != null) {
builder.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage)
.bigLargeIcon(null as Bitmap?)
)
}
nm.notify(System.currentTimeMillis().toInt(), builder.build())
}
private fun buildStatusNotification(): Notification {
@@ -223,10 +348,20 @@ class MatrixSyncService : Service() {
private fun ensureMessageChannel(nm: NotificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val soundUri = Uri.parse("android.resource://$packageName/${R.raw.paarrot_notification}")
val channel = NotificationChannel(
CHANNEL_MESSAGES, "Messages",
NotificationManager.IMPORTANCE_HIGH,
).apply { description = "Matrix message notifications" }
).apply {
description = "Matrix message notifications"
setSound(
soundUri,
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
}
nm.createNotificationChannel(channel)
}
}
@@ -243,9 +378,9 @@ class MatrixSyncService : Service() {
private const val KEY_LAST_WAKE_MS = "last_wake_ms"
private const val NOTIF_ID_STATUS = 1001
private const val CHANNEL_STATUS = "sync_status"
private const val CHANNEL_MESSAGES = "messages"
private const val CHANNEL_MESSAGES = "messages_paarrot"
private const val MIN_WAKE_INTERVAL_MS = 7_500L
const val ACTION_PUSH_PING = "com.paarrot.app.ACTION_PUSH_PING"
const val MODE_ONE_SHOT = "one_shot"
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */
@Volatile

View File

@@ -1,25 +0,0 @@
package com.paarrot.app
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* Receives tiny push wake pings and triggers one Matrix fetch.
*
* - `org.unifiedpush.android.connector.MESSAGE` supports UnifiedPush distributors.
* - [MatrixSyncService.ACTION_PUSH_PING] is an app-local fallback action
* that other bridges (e.g., FCM service) can emit.
*/
class PushPingReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val reason = when (intent.action) {
"org.unifiedpush.android.connector.MESSAGE" -> "unifiedpush_ping"
MatrixSyncService.ACTION_PUSH_PING -> "app_push_ping"
else -> return
}
MatrixSyncService.requestSyncFetch(context, reason)
}
}

View File

@@ -0,0 +1,196 @@
package com.paarrot.app
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
import java.io.FileOutputStream
/** Payload for a single shared file. */
data class SharedFilePayload(
val path: String,
val name: String,
val mimeType: String,
val size: Long,
)
/** Full payload delivered to JS when a share intent is received. */
data class SharePayload(
val text: String?,
val subject: String?,
val files: List<SharedFilePayload>,
val receivedAt: Long,
) {
/** Serialize to a [JSObject] for the Capacitor bridge. */
fun toJsObject(): JSObject {
val obj = JSObject()
obj.put("text", text)
obj.put("subject", subject)
val filesArray = JSArray()
for (f in files) {
val fileObj = JSObject()
fileObj.put("path", f.path)
fileObj.put("name", f.name)
fileObj.put("mimeType", f.mimeType)
fileObj.put("size", f.size)
filesArray.put(fileObj)
}
obj.put("files", filesArray)
obj.put("receivedAt", receivedAt)
return obj
}
}
/** Singleton that bridges between Android activity lifecycle and the Capacitor plugin. */
object ShareIntentStore {
var plugin: ShareHandlerPlugin? = null
var pendingShare: SharePayload? = null
/** Parse [intent] and, if it is a share action, store the payload and notify JS. */
fun handleIntent(context: Context, intent: Intent?) {
if (intent == null) return
val action = intent.action ?: return
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
val receivedAt = System.currentTimeMillis()
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val uris = mutableListOf<Uri>()
if (action == Intent.ACTION_SEND_MULTIPLE) {
@Suppress("UNCHECKED_CAST")
val list = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
list?.let { uris.addAll(it) }
} else {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
uri?.let { uris.add(it) }
}
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
val payload = SharePayload(
text = text,
subject = subject,
files = copiedFiles,
receivedAt = receivedAt,
)
pendingShare = payload
plugin?.emitShare(payload)
}
private fun copySharedUri(context: Context, uri: Uri, receivedAt: Long): SharedFilePayload? {
return try {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "application/octet-stream"
val originalName = resolver.query(uri, arrayOf("_display_name"), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
} ?: uri.lastPathSegment ?: "shared_file"
val safeName = sanitizeFileName(originalName)
val destDir = File(context.cacheDir, "shared-intents/$receivedAt")
destDir.mkdirs()
var destFile = File(destDir, safeName)
var counter = 1
while (destFile.exists()) {
val dot = safeName.lastIndexOf('.')
val base = if (dot >= 0) safeName.substring(0, dot) else safeName
val ext = if (dot >= 0) safeName.substring(dot) else ""
destFile = File(destDir, "${base}_$counter$ext")
counter++
}
resolver.openInputStream(uri)?.use { input ->
FileOutputStream(destFile).use { output -> input.copyTo(output) }
}
SharedFilePayload(
path = destFile.absolutePath,
name = destFile.name,
mimeType = mimeType,
size = destFile.length(),
)
} catch (e: Exception) {
android.util.Log.e("ShareHandlerPlugin", "Failed to copy shared URI: $uri", e)
null
}
}
private fun sanitizeFileName(name: String): String {
return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_").take(128).ifEmpty { "file" }
}
/** Remove cached files from a previous share and clear the stored payload. */
fun clearIntent(receivedAt: Long?) {
if (receivedAt != null) {
val dir = plugin?.context?.let { File(it.cacheDir, "shared-intents/$receivedAt") }
dir?.deleteRecursively()
}
pendingShare = null
}
}
/** Capacitor plugin that exposes Android share intents to JavaScript. */
@CapacitorPlugin(name = "AndroidShareHandler")
class ShareHandlerPlugin : Plugin() {
override fun load() {
ShareIntentStore.plugin = this
ShareIntentStore.handleIntent(context, activity.intent)
}
override fun handleOnNewIntent(intent: Intent?) {
super.handleOnNewIntent(intent)
ShareIntentStore.handleIntent(context, intent)
}
/** Fire the `shareReceived` event toward the JS layer. */
fun emitShare(payload: SharePayload) {
notifyListeners("shareReceived", payload.toJsObject())
}
/** Returns the pending share payload (if any) as a JS object. */
@PluginMethod
fun getPendingShare(call: PluginCall) {
val pending = ShareIntentStore.pendingShare
if (pending == null) {
val result = JSObject()
result.put("share", JSObject.NULL)
call.resolve(result)
} else {
val result = JSObject()
result.put("share", pending.toJsObject())
call.resolve(result)
}
}
/** Clears the pending share and removes any copied temp files. */
@PluginMethod
fun clearPendingShare(call: PluginCall) {
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
call.resolve()
}
}

View File

@@ -12,16 +12,30 @@ import com.getcapacitor.annotation.CapacitorPlugin
* Capacitor plugin that controls [MatrixSyncService] from JS.
*
* JS API:
* - `start({ homeserverUrl, accessToken, userId, deviceId })` — persist/update sync credentials
* - `start({ homeserverUrl, accessToken, userId, deviceId })` — persist credentials and register UnifiedPush
* - `triggerPing({ reason })` — force a one-shot fetch (for testing/manual wake)
* - `stop()` — clear persisted credentials and stop any in-flight fetch
* - `stop()` — clear persisted credentials and unregister UnifiedPush
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
* - `getStatus()` — returns `{ running: boolean }`
* - `getStatus()` — returns current fetch and UnifiedPush state
*/
@CapacitorPlugin(name = "MatrixBackgroundSync")
class SyncServicePlugin : Plugin() {
/** Persists Matrix credentials used later by push-triggered sync fetches. */
fun emitUnifiedPushEvent(eventName: String, payload: JSObject) {
notifyListeners(eventName, payload, true)
}
override fun load() {
super.load()
UnifiedPushManager.setPlugin(this)
}
override fun handleOnDestroy() {
UnifiedPushManager.clearPlugin(this)
super.handleOnDestroy()
}
/** Persists Matrix credentials and starts UnifiedPush registration. */
@PluginMethod
fun start(call: PluginCall) {
val homeserver = call.getString("homeserverUrl")
@@ -38,6 +52,8 @@ class SyncServicePlugin : Plugin() {
.putString(MatrixSyncService.EXTRA_DEVICE_ID, deviceId)
.apply()
UnifiedPushManager.register(context, activity)
call.resolve()
}
@@ -54,6 +70,7 @@ class SyncServicePlugin : Plugin() {
fun stop(call: PluginCall) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
context.stopService(Intent(context, MatrixSyncService::class.java))
UnifiedPushManager.unregister(context)
call.resolve()
}
@@ -76,7 +93,8 @@ class SyncServicePlugin : Plugin() {
@Suppress("DEPRECATION")
val running = am.getRunningServices(Int.MAX_VALUE)
.any { it.service.className == MatrixSyncService::class.java.name }
call.resolve(JSObject().apply { put("running", running) })
val result = UnifiedPushManager.getStatus(context).apply { put("running", running) }
call.resolve(result)
}
companion object {

View File

@@ -0,0 +1,149 @@
package com.paarrot.app
import android.app.Activity
import android.content.Context
import android.util.Log
import com.getcapacitor.JSObject
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.INSTANCE_DEFAULT
import org.unifiedpush.android.connector.UnifiedPush
/** Coordinates UnifiedPush registration state and bridges events back to JS. */
object UnifiedPushManager {
private const val TAG = "UnifiedPushManager"
private const val PREFS = "unifiedpush_prefs"
private const val KEY_ENDPOINT = "endpoint"
private const val KEY_INSTANCE = "instance"
private const val EVENT_NEW_ENDPOINT = "unifiedPushNewEndpoint"
private const val EVENT_UNREGISTERED = "unifiedPushUnregistered"
private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed"
private const val DEFAULT_MESSAGE = "Paarrot notifications"
@Volatile
private var plugin: SyncServicePlugin? = null
fun setPlugin(plugin: SyncServicePlugin) {
this.plugin = plugin
}
fun clearPlugin(plugin: SyncServicePlugin) {
if (this.plugin === plugin) {
this.plugin = null
}
}
fun register(context: Context, activity: Activity?) {
val savedDistributor = runCatching { UnifiedPush.getSavedDistributor(context) }.getOrNull().orEmpty()
if (savedDistributor.isNotBlank()) {
requestRegistration(context)
return
}
if (activity == null) {
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
return
}
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success ->
if (success) {
requestRegistration(context)
} else {
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
}
}
}
fun unregister(context: Context) {
runCatching {
UnifiedPush.unregister(context, INSTANCE_DEFAULT)
}.onFailure {
Log.w(TAG, "Failed to unregister UnifiedPush: ${it.message}")
}
val previousEndpoint = getEndpoint(context)
clearEndpoint(context)
dispatchUnregistered(previousEndpoint, INSTANCE_DEFAULT)
}
fun getStatus(context: Context): JSObject {
val status = JSObject()
status.put("endpoint", getEndpoint(context) ?: "")
status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT)
status.put("registered", !getEndpoint(context).isNullOrBlank())
status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault(""))
status.put(
"distributors",
runCatching { UnifiedPush.getDistributors(context) }.getOrDefault(emptyList<String>())
)
return status
}
fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
val previousEndpoint = getEndpoint(context)
persistEndpoint(context, endpoint, instance)
val payload = JSObject().apply {
put("endpoint", endpoint)
put("instance", instance)
put("previousEndpoint", previousEndpoint ?: "")
}
dispatch(EVENT_NEW_ENDPOINT, payload)
}
fun onRegistrationFailed(reason: String, instance: String) {
dispatchRegistrationFailed(reason, instance)
}
fun onUnregistered(context: Context, instance: String) {
val previousEndpoint = getEndpoint(context)
clearEndpoint(context)
dispatchUnregistered(previousEndpoint, instance)
}
private fun requestRegistration(context: Context) {
runCatching {
UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null)
}.onFailure {
Log.w(TAG, "UnifiedPush register failed: ${it.message}")
dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT)
}
}
private fun dispatchUnregistered(previousEndpoint: String?, instance: String) {
val payload = JSObject().apply {
put("previousEndpoint", previousEndpoint ?: "")
put("instance", instance)
}
dispatch(EVENT_UNREGISTERED, payload)
}
private fun dispatchRegistrationFailed(reason: String, instance: String) {
val payload = JSObject().apply {
put("reason", reason)
put("instance", instance)
}
dispatch(EVENT_REGISTRATION_FAILED, payload)
}
private fun dispatch(eventName: String, payload: JSObject) {
plugin?.emitUnifiedPushEvent(eventName, payload)
}
private fun persistEndpoint(context: Context, endpoint: String, instance: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY_ENDPOINT, endpoint)
.putString(KEY_INSTANCE, instance)
.apply()
}
private fun clearEndpoint(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.remove(KEY_ENDPOINT)
.remove(KEY_INSTANCE)
.apply()
}
private fun getEndpoint(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null)
private fun getInstance(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_INSTANCE, null)
}

View File

@@ -0,0 +1,35 @@
package com.paarrot.app
import android.util.Log
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
/** Receives UnifiedPush events and turns push messages into Matrix fetch wakes. */
class UnifiedPushService : PushService() {
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
Log.i(TAG, "UnifiedPush endpoint updated for instance=$instance")
UnifiedPushManager.onNewEndpoint(applicationContext, endpoint.url, instance)
}
override fun onMessage(message: PushMessage, instance: String) {
Log.d(TAG, "UnifiedPush wake ping received for instance=$instance, bytes=${message.content.size}")
MatrixSyncService.requestSyncFetch(applicationContext, "unifiedpush_message")
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
Log.w(TAG, "UnifiedPush registration failed: $reason")
UnifiedPushManager.onRegistrationFailed(reason.name, instance)
}
override fun onUnregistered(instance: String) {
Log.i(TAG, "UnifiedPush unregistered for instance=$instance")
UnifiedPushManager.onUnregistered(applicationContext, instance)
}
private companion object {
const val TAG = "UnifiedPushService"
}
}

View File

@@ -2,15 +2,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
android:viewportWidth="18"
android:viewportHeight="18">
<!--
Converted from public/res/svg/notification.svg.
Transforms flattened: outer(1.05922,0,0,1.05922,-0.5628,-0.4805)
x inner(1.6762,0,0,1.6762,-4.5071,-10.7901)
= scale 1.7754, translate(-5.337,-11.908)
-->
<group
android:scaleX="1.7754"
android:scaleY="1.7754"
android:translateX="-5.337"
android:translateY="-11.908">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M2,9.1C3.8,5.7 7.2,4 11.6,4c4.4,0 7.8,1.7 10.4,5.1l-2,1.6c-1.7,-1.5 -3.8,-2.4 -6.4,-2.7c-0.2,1.6 -1.6,2.9 -3.3,2.9c-1.7,0 -3.1,-1.2 -3.3,-2.8C5.1,8.5 3.5,9.1 2,10z" />
android:fillType="evenOdd"
android:pathData="M4.624,14.382C4.624,14.382 1.358,10.79 5.108,8.336C5.501,8.316 7.14,8.899 7.18,10.368C7.22,11.836 5.641,11.172 5.108,11.856C4.575,12.54 4.454,14.121 4.624,14.382Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M8.7,11.5c-2.8,0 -5.1,2.3 -5.1,5.1c0,3 2.4,5.4 5.5,5.4c2.9,0 5.4,-1.3 7,-3.7c-0.5,0.1 -0.9,0.2 -1.4,0.2c-3.5,0 -6.4,-2.8 -6.4,-6.3c0,-0.2 0,-0.4 0,-0.7z" />
android:fillType="evenOdd"
android:pathData="M5.08,13.547C5.08,13.547 7.28,14.842 7.291,10.74C7.592,10.971 8.588,12.107 7.713,13.777C6.838,15.446 5.392,14.322 5.08,13.547Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M14.7,12.3c3,0.1 5.3,1.6 7.3,4.2c-1.8,1.5 -4.1,2.4 -6.9,2.8c1,-1 1.6,-2.3 1.6,-3.8c0,-1.2 -0.3,-2.3 -0.9,-3.2z" />
android:fillType="evenOdd"
android:pathData="M5.921,14.763C5.584,14.449 6.374,15.403 6.704,16.642C6.873,16.232 8.282,17.562 11.166,14.247C12.324,12.581 13.244,11.279 11.249,8.874C9.254,6.469 6.507,6.615 5.469,7.581C4.431,8.546 5.35,7.566 6.653,8.667C7.52,9.399 7.444,10.431 7.444,10.431C7.774,10.551 9.12,12.332 7.935,14.099C7.205,15.187 6.259,15.076 5.921,14.763Z" />
</group>
</vector>

Binary file not shown.

View File

@@ -2,7 +2,7 @@
buildscript {
ext {
kotlin_version = '1.9.25'
kotlin_version = '2.3.0'
}
repositories {

2
cinny

Submodule cinny updated: 7b69ed6df8...ffec244113

BIN
full_logcat.txt Normal file

Binary file not shown.

View File

@@ -0,0 +1,255 @@
import { useAtomValue } from 'jotai';
import React, { ChangeEventHandler, useCallback, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Header,
Icon,
IconButton,
Icons,
Input,
MenuItem,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
config,
} from 'folds';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
import { allRoomsAtom } from '../state/room-list/roomList';
import { mDirectAtom } from '../state/mDirectList';
import { useDirects, useRooms } from '../state/hooks/roomList';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
import { RoomAvatar, RoomIcon } from '../components/room-avatar';
import { nameInitials } from '../utils/common';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { factoryRoomIdByActivity } from '../utils/sort';
import {
SearchItemStrGetter,
useAsyncSearch,
UseAsyncSearchOptions,
} from '../hooks/useAsyncSearch';
import { VirtualTile } from '../components/virtualizer';
import { AndroidSharePayload } from '../utils/androidShare';
import { stopPropagation } from '../utils/keyboard';
const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 200,
matchOptions: {
contain: true,
},
};
type ShareRoomPickerProps = {
/** The pending share payload to display a preview of. */
share: AndroidSharePayload;
/** Called when the user picks a room. */
onPick: (roomId: string) => void;
/** Called when the user dismisses without picking. */
onDismiss: () => void;
};
/**
* Full-screen modal that lets the user pick a room to send
* an incoming Android share intent into.
*/
export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const scrollRef = useRef<HTMLDivElement>(null);
const mDirects = useAtomValue(mDirectAtom);
const allRoomsSet = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allRoomsSet);
const rooms = useRooms(mx, allRoomsAtom, mDirects);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const allItems = useMemo(
() => [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)),
[mx, rooms, directs]
);
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
(rId) => getRoom(rId)?.name ?? rId,
[getRoom]
);
const [searchResult, searchRoom, resetSearch] = useAsyncSearch(
allItems,
getRoomNameStr,
SEARCH_OPTS
);
const items = searchResult ? searchResult.items : allItems;
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 48,
overscan: 8,
});
const vItems = virtualizer.getVirtualItems();
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const value = evt.currentTarget.value.trim();
if (!value) {
resetSearch();
return;
}
searchRoom(value);
};
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
const roomId = evt.currentTarget.getAttribute('data-room-id');
if (roomId) {
onPick(roomId);
}
};
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
const fileCount = share.files.length;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: onDismiss,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300">
<Box grow="Yes" direction="Column" style={{ maxHeight: '85vh' }}>
<Header
size="500"
style={{ padding: config.space.S200, paddingLeft: config.space.S400 }}
>
<Box grow="Yes" direction="Column" gap="100">
<Text size="H4">Share to Room</Text>
{(previewText || fileCount > 0) && (
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
{previewText
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
</Text>
)}
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={onDismiss}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Box
style={{
padding: `0 ${config.space.S300}`,
paddingTop: config.space.S200,
paddingBottom: config.space.S200,
}}
>
<Input
onChange={handleSearchChange}
before={<Icon size="200" src={Icons.Search} />}
placeholder="Search rooms…"
size="400"
variant="Background"
outlined
autoFocus
/>
</Box>
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
<Box
style={{ padding: config.space.S300, paddingTop: 0 }}
direction="Column"
>
{vItems.length === 0 && (
<Box
style={{ padding: `${config.space.S700} 0` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
{searchResult ? 'No Match Found' : 'No Rooms'}
</Text>
<Text size="T200" align="Center" priority="300">
{searchResult
? `No rooms found for "${searchResult.query}".`
: 'You have no rooms to share into.'}
</Text>
</Box>
)}
<Box
style={{ position: 'relative', height: virtualizer.getTotalSize() }}
>
{vItems.map((vItem) => {
const roomId = items[vItem.index];
const room = getRoom(roomId);
if (!room) return null;
const isDm = mDirects.has(roomId);
const avatarSrc = isDm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={roomId}
>
<MenuItem
data-room-id={roomId}
onClick={handleRoomClick}
variant="Surface"
size="400"
radii="400"
before={
<Avatar size="300" radii={isDm ? '400' : '300'}>
{avatarSrc ? (
<RoomAvatar
roomId={roomId}
src={avatarSrc}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon room={room} size="200" filled />
)}
</Avatar>
}
>
<Box grow="Yes" direction="Column">
<Text size="B400" truncate>
{room.name}
</Text>
</Box>
</MenuItem>
</VirtualTile>
);
})}
</Box>
</Box>
</Scroll>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
@@ -11,6 +11,12 @@ import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
import {
isBackgroundSyncSupported,
getBackgroundSyncStatus,
requestResetPushRegistration,
triggerBackgroundSyncPing,
} from '../../../utils/backgroundSync';
function EmailNotification() {
const mx = useMatrixClient();
@@ -147,6 +153,7 @@ export function SystemNotification() {
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
/>
</SequenceCard>
{isBackgroundSyncSupported() && <AndroidPushNotifications />}
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
@@ -158,3 +165,126 @@ export function SystemNotification() {
</Box>
);
}
type PushStatus = {
registered: boolean;
distributor: string;
endpoint: string;
};
/** Android-only section showing UnifiedPush registration status and controls. */
function AndroidPushNotifications() {
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
const s = await getBackgroundSyncStatus();
setStatus(
s
? {
registered: s.registered,
distributor: s.distributor || '',
endpoint: s.endpoint || '',
}
: undefined
);
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const [resetState, reset] = useAsyncCallback(
useCallback(async () => {
await requestResetPushRegistration();
await refresh();
}, [refresh])
);
const [pingState, ping] = useAsyncCallback(
useCallback(async () => {
await triggerBackgroundSyncPing('manual-test');
}, [])
);
const isBusy =
loading ||
resetState.status === AsyncStatus.Loading ||
pingState.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="100">
<Text size="L400">Android Push (UnifiedPush)</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Background Notifications"
description={
loading ? (
'Loading status…'
) : status === undefined ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Failed to read push status.
</Text>
) : status.registered ? (
<>
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
</Text>
</>
) : (
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
Not registered. Tap &quot;Reset&quot; to choose a distributor app.
</Text>
)
}
after={loading ? <Spinner variant="Secondary" /> : undefined}
/>
<SettingTile
title="Change Distributor"
description="Re-open the UnifiedPush distributor selection dialog."
after={
<Button
size="300"
radii="300"
variant="Secondary"
disabled={isBusy}
onClick={() => void reset()}
>
{resetState.status === AsyncStatus.Loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Text size="B300">Reset</Text>
)}
</Button>
}
/>
<SettingTile
title="Test Notification"
description="Trigger a test push ping to verify the pipeline works."
after={
<Button
size="300"
radii="300"
variant="Secondary"
disabled={isBusy}
onClick={() => void ping()}
>
{pingState.status === AsyncStatus.Loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Text size="B300">Send Test</Text>
)}
</Button>
}
/>
</SequenceCard>
</Box>
);
}

View File

@@ -1,5 +1,6 @@
import { useAtomValue } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useAtom, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
@@ -28,6 +29,8 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { plainToEditorInput, toPlainText } from '../../components/editor';
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
import {
isTauri,
isElectron,
@@ -41,6 +44,20 @@ import {
stopBackgroundSync,
setAppForegroundState,
} from '../../utils/backgroundSync';
import {
TUploadItem,
roomIdToMsgDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
} from '../../state/room/roomInputDrafts';
import { encryptFile } from '../../utils/matrix';
import {
AndroidSharePayload,
clearPendingAndroidShare,
getPendingAndroidShare,
isAndroidShareSupported,
listenForAndroidShares,
materializeSharedFile,
} from '../../utils/androidShare';
/**
* Applies the selected emoji style font to the document.
@@ -426,8 +443,13 @@ function MessageNotifications() {
*/
function BackgroundSyncSetup() {
const mx = useMatrixClient();
// Try to make a simple fetch to verify component is rendering
try {
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
} catch {}
useEffect(() => {
console.log('BackgroundSyncSetup: Starting background sync for', mx.getUserId());
startBackgroundSync(mx);
const onVisibility = () => setAppForegroundState(!document.hidden);
@@ -493,6 +515,135 @@ function TaskbarFlashStopper() {
return null;
}
function AndroidShareIntentHandler() {
const mx = useMatrixClient();
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
const [pickedRoomId, setPickedRoomId] = useState<string | null>(null);
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(pickedRoomId ?? '__android_share__'));
const setUploadItems = useSetAtom(roomIdToUploadItemsAtomFamily(pickedRoomId ?? '__android_share__'));
const applyPendingShare = useCallback(
async (share: AndroidSharePayload, roomId: string) => {
const room = mx.getRoom(roomId);
if (!room) return false;
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
(value): value is string => !!value
);
if (nextParts.length > 0) {
const existingText = toPlainText(msgDraft, isMarkdown).trim();
const nextText = existingText ? `${existingText}\n${nextParts.join('\n')}` : nextParts.join('\n');
setMsgDraft(plainToEditorInput(nextText, isMarkdown));
}
if (share.files.length > 0) {
const uploadItems: TUploadItem[] = [];
for (const sharedFile of share.files) {
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
if (room.hasEncryptionStateEvent()) {
const encrypted = await encryptFile(originalFile);
uploadItems.push({
file: encrypted.file,
originalFile,
metadata: { markedAsSpoiler: false },
encInfo: encrypted.encInfo,
});
continue;
}
uploadItems.push({
file: originalFile,
originalFile,
metadata: { markedAsSpoiler: false },
encInfo: undefined,
});
}
setUploadItems({
type: 'PUT',
item: uploadItems,
});
}
await clearPendingAndroidShare();
return true;
},
[isMarkdown, msgDraft, mx, setMsgDraft, setUploadItems]
);
const handlePickRoom = useCallback(
(roomId: string) => {
setPickedRoomId(roomId);
if (!pendingShare) return;
applyPendingShare(pendingShare, roomId)
.then((applied) => {
if (applied) {
setPendingShare(null);
setPickedRoomId(null);
}
})
.catch((err) => {
console.error('[AndroidShare] Failed to apply share after pick:', err);
setPendingShare(null);
setPickedRoomId(null);
});
},
[applyPendingShare, pendingShare]
);
const handleDismiss = useCallback(() => {
clearPendingAndroidShare().catch(() => {});
setPendingShare(null);
setPickedRoomId(null);
}, []);
useEffect(() => {
if (!isAndroidShareSupported()) return;
let mounted = true;
let listenerHandle: { remove: () => Promise<void> } | undefined;
getPendingAndroidShare()
.then((share) => {
if (mounted && share) {
setPendingShare(share);
}
})
.catch((err) => {
console.error('[AndroidShare] Failed to get pending share:', err);
});
listenForAndroidShares((share) => {
if (mounted) {
setPendingShare(share);
}
})
.then((handle) => {
listenerHandle = handle;
})
.catch((err) => {
console.error('[AndroidShare] Failed to listen for shares:', err);
});
return () => {
mounted = false;
void listenerHandle?.remove();
};
}, []);
if (!pendingShare) return null;
return (
<ShareRoomPicker
share={pendingShare}
onPick={handlePickRoom}
onDismiss={handleDismiss}
/>
);
}
type ClientNonUIFeaturesProps = {
children: ReactNode;
};
@@ -508,6 +659,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<BackgroundSyncSetup />
<PaarrotAPIInitializer />
<TaskbarFlashStopper />
<AndroidShareIntentHandler />
{children}
</>
);

View File

@@ -0,0 +1,73 @@
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
/** Metadata for a file received from an Android share intent. */
export type AndroidSharedFile = {
path: string;
name: string;
mimeType: string;
size: number;
};
/** Payload persisted by the native Android share handler. */
export type AndroidSharePayload = {
text?: string;
subject?: string;
files: AndroidSharedFile[];
receivedAt: number;
};
interface AndroidShareHandlerPlugin {
/** Returns the last pending share payload, if one exists. */
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
/** Clears the last pending share payload after it has been consumed. */
clearPendingShare(): Promise<void>;
addListener(
eventName: 'shareReceived',
listenerFunc: (payload: AndroidSharePayload) => void
): Promise<PluginListenerHandle>;
}
const AndroidShareHandler = registerPlugin<AndroidShareHandlerPlugin>('AndroidShareHandler');
/** Returns true when the Android share bridge is available. */
export const isAndroidShareSupported = (): boolean =>
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
/** Fetches the current pending native share payload. */
export const getPendingAndroidShare = async (): Promise<AndroidSharePayload | null> => {
if (!isAndroidShareSupported()) return null;
const result = await AndroidShareHandler.getPendingShare();
return result.share;
};
/** Clears the native pending share payload. */
export const clearPendingAndroidShare = async (): Promise<void> => {
if (!isAndroidShareSupported()) return;
await AndroidShareHandler.clearPendingShare();
};
/** Subscribes to new incoming native share payloads. */
export const listenForAndroidShares = async (
listener: (payload: AndroidSharePayload) => void
): Promise<PluginListenerHandle | undefined> => {
if (!isAndroidShareSupported()) return undefined;
return AndroidShareHandler.addListener('shareReceived', listener);
};
/** Converts a cached native shared file into a browser File for upload. */
export const materializeSharedFile = async (
sharedFile: AndroidSharedFile,
receivedAt: number
): Promise<File> => {
const fileUrl = Capacitor.convertFileSrc(sharedFile.path);
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to read shared file: ${sharedFile.name}`);
}
const blob = await response.blob();
return new File([blob], sharedFile.name, {
type: sharedFile.mimeType || blob.type,
lastModified: receivedAt,
});
};

View File

@@ -1,8 +1,33 @@
import { Capacitor, registerPlugin } from '@capacitor/core';
import type { MatrixClient } from 'matrix-js-sdk';
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
type UnifiedPushStatus = {
running: boolean;
endpoint: string;
instance: string;
registered: boolean;
distributor: string;
distributors: string[] | string;
};
type UnifiedPushEndpointEvent = {
endpoint: string;
previousEndpoint: string;
instance: string;
};
type UnifiedPushUnregisteredEvent = {
previousEndpoint: string;
instance: string;
};
type UnifiedPushRegistrationFailedEvent = {
reason: string;
instance: string;
};
interface MatrixBackgroundSyncPlugin {
/** Persist credentials used by push-triggered one-shot sync fetches. */
/** Persist credentials and request UnifiedPush registration. */
start(options: {
homeserverUrl: string;
accessToken: string;
@@ -11,7 +36,9 @@ interface MatrixBackgroundSyncPlugin {
}): Promise<void>;
/** Trigger a one-shot fetch as if a push ping arrived. */
triggerPing(options: { reason?: string }): Promise<void>;
/** Stop any in-flight fetch and clear persisted credentials. */
/** Re-open distributor setup flow and retry registration. */
requestDistributorSetup(): Promise<{ success: boolean }>;
/** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */
stop(): Promise<void>;
/**
* Notify the service whether the app UI is visible.
@@ -19,45 +46,408 @@ interface MatrixBackgroundSyncPlugin {
* because the JS layer handles them via LocalNotifications.
*/
setAppForeground(options: { foreground: boolean }): Promise<void>;
/** Returns whether the sync service is currently running. */
getStatus(): Promise<{ running: boolean }>;
/** Returns fetch state and current UnifiedPush registration details. */
getStatus(): Promise<UnifiedPushStatus>;
addListener(
eventName: 'unifiedPushNewEndpoint',
listenerFunc: (event: UnifiedPushEndpointEvent) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'unifiedPushUnregistered',
listenerFunc: (event: UnifiedPushUnregisteredEvent) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'unifiedPushRegistrationFailed',
listenerFunc: (event: UnifiedPushRegistrationFailedEvent) => void
): Promise<PluginListenerHandle>;
}
type StoredPusherState = {
endpoint: string;
appId: string;
};
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
const DEFAULT_UNIFIED_PUSH_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify';
const PUSHER_APP_ID_BASE = 'com.paarrot.app.android';
const PUSHER_STORAGE_PREFIX = 'paarrot.unifiedpush';
/** Returns true when the current platform is Android Capacitor. */
export const isBackgroundSyncSupported = (): boolean =>
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
/**
* Persists Matrix credentials for Android push-ping wake handling.
* Safe to call on every login.
* @param mx Authenticated Matrix client
*/
export const startBackgroundSync = async (mx: MatrixClient): Promise<void> => {
if (!isBackgroundSyncSupported()) return;
const getStoredPusherKey = (userId: string | null, deviceId: string | null): string =>
`${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`;
const buildPusherAppId = (deviceId: string | null): string => {
const raw = deviceId ? `${PUSHER_APP_ID_BASE}.${deviceId}` : PUSHER_APP_ID_BASE;
return raw.length > 64 ? raw.slice(0, 64) : raw;
};
const loadStoredPusherState = (
userId: string | null,
deviceId: string | null
): StoredPusherState | undefined => {
if (typeof window === 'undefined' || !window.localStorage) return undefined;
const raw = window.localStorage.getItem(getStoredPusherKey(userId, deviceId));
if (!raw) return undefined;
try {
return JSON.parse(raw) as StoredPusherState;
} catch {
return undefined;
}
};
const saveStoredPusherState = (
userId: string | null,
deviceId: string | null,
state: StoredPusherState
): void => {
if (typeof window === 'undefined' || !window.localStorage) return;
window.localStorage.setItem(getStoredPusherKey(userId, deviceId), JSON.stringify(state));
};
const clearStoredPusherState = (userId: string | null, deviceId: string | null): void => {
if (typeof window === 'undefined' || !window.localStorage) return;
window.localStorage.removeItem(getStoredPusherKey(userId, deviceId));
};
const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[] => {
if (Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return [];
const trimmed = raw.trim();
if (!trimmed || trimmed === '[]') return [];
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
return trimmed
.slice(1, -1)
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
return [trimmed];
};
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
try {
const discoveryUrl = new URL(endpoint);
discoveryUrl.pathname = '/_matrix/push/v1/notify';
discoveryUrl.search = '';
const response = await fetch(discoveryUrl.toString(), { method: 'GET' });
if (!response.ok) return DEFAULT_UNIFIED_PUSH_GATEWAY;
const body = (await response.json()) as {
gateway?: string;
unifiedpush?: { gateway?: string };
};
if (body.gateway === 'matrix' || body.unifiedpush?.gateway === 'matrix') {
return discoveryUrl.toString();
}
} catch (err) {
console.warn('[BackgroundSync] UnifiedPush gateway discovery failed:', err);
}
return DEFAULT_UNIFIED_PUSH_GATEWAY;
};
class AndroidUnifiedPushManager {
private client: MatrixClient | undefined;
private listenerHandles: PluginListenerHandle[] = [];
private listenersReady = false;
private distributorSetupAttempted = false;
private distributorPromptShown = false;
/** Start native UnifiedPush registration and synchronize the Matrix pusher. */
async start(mx: MatrixClient): Promise<void> {
console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform());
if (!isBackgroundSyncSupported()) {
console.log('[BackgroundSync] Background sync not supported on this platform');
return;
}
const homeserverUrl = mx.getHomeserverUrl();
const accessToken = mx.getAccessToken();
const userId = mx.getUserId();
const deviceId = mx.getDeviceId();
console.log('[BackgroundSync] Credentials check:', { userId, deviceId, hasToken: !!accessToken, hasUrl: !!homeserverUrl });
if (!homeserverUrl || !accessToken || !userId) {
console.warn('[BackgroundSync] Missing credentials, not starting');
return;
}
this.client = mx;
this.distributorSetupAttempted = false;
this.distributorPromptShown = false;
await this.ensureListeners();
try {
console.log('[BackgroundSync] Calling plugin.start()...');
await MatrixBackgroundSync.start({
homeserverUrl,
accessToken,
userId,
deviceId: deviceId ?? '',
});
console.log('[BackgroundSync] Push wake credentials configured');
console.log('[BackgroundSync] plugin.start() completed');
} catch (err) {
console.warn('[BackgroundSync] Failed to start:', err);
console.error('[BackgroundSync] plugin.start() failed:', err);
throw err;
}
await this.syncExistingEndpoint();
await this.ensureDistributorPromptFromStatus();
console.log('[BackgroundSync] UnifiedPush registration requested');
}
/** Stop native UnifiedPush integration and remove the Matrix pusher for this device. */
async stop(): Promise<void> {
if (!isBackgroundSyncSupported()) return;
const client = this.client;
if (client) {
const deviceId = client.getDeviceId();
const userId = client.getUserId();
const status = await this.safeGetStatus();
const stored = loadStoredPusherState(userId, deviceId);
const endpoint = status?.endpoint || stored?.endpoint;
const appId = stored?.appId ?? buildPusherAppId(deviceId);
if (endpoint) {
await this.removePusher(client, endpoint, appId);
}
clearStoredPusherState(userId, deviceId);
}
await MatrixBackgroundSync.stop();
await this.disposeListeners();
this.client = undefined;
console.log('[BackgroundSync] UnifiedPush stopped');
}
/** Update the Matrix pusher when a new native endpoint is published. */
private async handleNewEndpoint(event: UnifiedPushEndpointEvent): Promise<void> {
const client = this.client;
if (!client) return;
if (event.previousEndpoint) {
await this.removePusher(client, event.previousEndpoint);
}
await this.upsertPusher(client, event.endpoint);
}
/** Remove the Matrix pusher when UnifiedPush unregisters this instance. */
private async handleUnregistered(event: UnifiedPushUnregisteredEvent): Promise<void> {
const client = this.client;
if (!client) return;
const stored = loadStoredPusherState(client.getUserId(), client.getDeviceId());
const endpoint = event.previousEndpoint || stored?.endpoint;
const appId = stored?.appId ?? buildPusherAppId(client.getDeviceId());
if (endpoint) {
await this.removePusher(client, endpoint, appId);
}
clearStoredPusherState(client.getUserId(), client.getDeviceId());
}
/** Log native registration failures so the missing distributor path is visible. */
private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void {
console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason);
if (event.reason !== 'ACTION_REQUIRED' || this.distributorSetupAttempted) {
return;
}
this.distributorSetupAttempted = true;
void this.tryRequestDistributorSetup();
}
/** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
private async tryRequestDistributorSetup(): Promise<void> {
try {
const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
const status = await this.safeGetStatus();
console.warn('[BackgroundSync] UnifiedPush status after setup attempt:', status);
const distributors = normalizeDistributors(status?.distributors ?? []);
if (distributors.length === 0) {
console.error(
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
);
this.showNoDistributorPrompt();
}
} catch (err) {
console.warn('[BackgroundSync] requestDistributorSetup failed:', err);
}
}
/** Fallback check so users still get prompted even when ACTION_REQUIRED event is missed. */
private async ensureDistributorPromptFromStatus(): Promise<void> {
const status = await this.safeGetStatus();
if (!status) return;
const distributors = normalizeDistributors(status.distributors);
if (!status.registered && distributors.length === 0) {
console.error(
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
);
this.showNoDistributorPrompt();
}
}
/** Show a one-time actionable prompt when no distributor app is installed. */
private showNoDistributorPrompt(): void {
if (this.distributorPromptShown) return;
this.distributorPromptShown = true;
const message =
'Android background notifications need a UnifiedPush distributor app. Install one (for example ntfy), then reopen Paarrot.';
const docsUrl = 'https://unifiedpush.org/users/distributors/';
if (typeof window === 'undefined') return;
try {
const openDocs = window.confirm(`${message}\n\nOpen distributor list now?`);
if (openDocs) {
window.open(docsUrl, '_blank', 'noopener,noreferrer');
}
} catch {
window.alert(message);
}
}
/** Install plugin listeners once for the active Matrix client. */
private async ensureListeners(): Promise<void> {
if (this.listenersReady) return;
this.listenerHandles = [
await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
void this.handleNewEndpoint(event).catch((err) => {
console.error('[BackgroundSync] handleNewEndpoint failed:', err);
});
}),
await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => {
void this.handleUnregistered(event);
}),
await MatrixBackgroundSync.addListener('unifiedPushRegistrationFailed', (event) => {
this.handleRegistrationFailed(event);
}),
];
this.listenersReady = true;
}
/** Remove all plugin listeners when the manager stops. */
private async disposeListeners(): Promise<void> {
await Promise.all(this.listenerHandles.map((handle) => handle.remove()));
this.listenerHandles = [];
this.listenersReady = false;
}
/** Reconcile an already-persisted native endpoint after app startup. */
private async syncExistingEndpoint(): Promise<void> {
const client = this.client;
if (!client) return;
const status = await this.safeGetStatus();
console.log('[BackgroundSync] Native status before pusher sync:', status);
if (status?.registered && status.endpoint) {
await this.upsertPusher(client, status.endpoint);
}
}
/** Create or refresh the Matrix HTTP pusher for the current device. */
private async upsertPusher(mx: MatrixClient, endpoint: string): Promise<void> {
const deviceId = mx.getDeviceId();
const userId = mx.getUserId();
const appId = buildPusherAppId(deviceId);
const gatewayUrl = await resolveUnifiedPushGateway(endpoint);
console.log('[BackgroundSync] Upserting Matrix pusher', {
appId,
endpoint,
gatewayUrl,
deviceId,
userId,
});
try {
await mx.setPusher({
kind: 'http',
app_id: appId,
pushkey: endpoint,
app_display_name: 'Paarrot',
device_display_name: deviceId ?? 'Android',
lang: 'en',
data: {
url: gatewayUrl,
format: 'event_id_only',
},
append: false,
device_id: deviceId ?? undefined,
} as unknown as IPusherRequest);
const pushers = (await mx.getPushers())?.pushers ?? [];
const thisPusher = pushers.find((p) => p.pushkey === endpoint && p.app_id === appId);
console.log('[BackgroundSync] Matrix pusher upserted successfully', {
totalPushers: pushers.length,
foundThisPusher: Boolean(thisPusher),
});
} catch (err) {
console.error('[BackgroundSync] Matrix pusher upsert failed:', err);
throw err;
}
saveStoredPusherState(userId, deviceId, { endpoint, appId });
}
/** Remove a previously-registered Matrix HTTP pusher for this device. */
private async removePusher(
mx: MatrixClient,
endpoint: string,
appId = buildPusherAppId(mx.getDeviceId())
): Promise<void> {
try {
await mx.setPusher({
pushkey: endpoint,
app_id: appId,
kind: null,
} as unknown as IPusherRequest);
} catch (err) {
console.warn('[BackgroundSync] Failed to remove UnifiedPush pusher:', err);
}
}
/** Read native registration state without failing the caller. */
private async safeGetStatus(): Promise<UnifiedPushStatus | undefined> {
try {
return await MatrixBackgroundSync.getStatus();
} catch (err) {
console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err);
return undefined;
}
}
}
const unifiedPushManager = new AndroidUnifiedPushManager();
/** Start native UnifiedPush registration and sync the Matrix pusher. */
export const startBackgroundSync = async (mx: MatrixClient): Promise<void> => {
await unifiedPushManager.start(mx);
};
/**
@@ -74,19 +464,9 @@ export const triggerBackgroundSyncPing = async (reason?: string): Promise<void>
}
};
/**
* Stops native background sync handling and clears persisted credentials.
* Call on logout.
*/
/** Stop UnifiedPush integration and remove the Matrix pusher for this session. */
export const stopBackgroundSync = async (): Promise<void> => {
if (!isBackgroundSyncSupported()) return;
try {
await MatrixBackgroundSync.stop();
console.log('[BackgroundSync] Service stopped');
} catch (err) {
console.warn('[BackgroundSync] Failed to stop:', err);
}
await unifiedPushManager.stop();
};
/**
@@ -104,3 +484,35 @@ export const setAppForegroundState = async (foreground: boolean): Promise<void>
console.warn('[BackgroundSync] setAppForeground failed:', err);
}
};
/**
* Re-opens the UnifiedPush distributor selection dialog.
* Allows users to re-select or change their push notification distributor without terminal access.
* Useful when the current endpoint becomes unavailable.
*/
export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
if (!isBackgroundSyncSupported()) {
console.warn('[BackgroundSync] Background sync not supported on this platform');
return { success: false };
}
try {
const result = await MatrixBackgroundSync.requestDistributorSetup();
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
return result ?? { success: false };
} catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false };
}
};
/** Returns the current native UnifiedPush status, or undefined if unavailable. */
export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | undefined> => {
if (!isBackgroundSyncSupported()) return undefined;
try {
return await MatrixBackgroundSync.getStatus();
} catch (err) {
console.warn('[BackgroundSync] getStatus failed:', err);
return undefined;
}
};

View File

@@ -1,6 +1,6 @@
{
"name": "paarrot",
"version": "4.11.88",
"version": "4.11.91",
"description": "Paarrot - A Matrix client based on Cinny",
"engines": {
"node": ">=18.0.0"

View File

@@ -30,6 +30,14 @@ const STASH_FLAG = join(ROOT, '.cinny-stash-pending');
const cinnyStatus = execSync('git status --porcelain', { cwd: CINNY, encoding: 'utf8' });
if (cinnyStatus.trim()) {
console.log('[apply-overlay] Stashing pre-existing cinny changes...');
// Ensure android/ junction is excluded from the cinny git view — otherwise
// `git stash -u` would traverse the junction into the root android/ directory
// and delete files there when the stash is created.
const cinnyExclude = join(CINNY, '.git', 'info', 'exclude');
const excludeContent = existsSync(cinnyExclude) ? readFileSync(cinnyExclude, 'utf8') : '';
if (!excludeContent.includes('android/')) {
writeFileSync(cinnyExclude, excludeContent + (excludeContent.endsWith('\n') || !excludeContent ? '' : '\n') + 'android/\n', 'utf8');
}
execSync('git stash push -u -m "pre-overlay stash"', { cwd: CINNY, stdio: 'inherit' });
writeFileSync(STASH_FLAG, '1', 'utf8');
console.log('[apply-overlay] Stash saved.');
@@ -120,21 +128,19 @@ if (!existsSync(androidJunction)) {
console.log(` android/ link already exists, skipping`);
}
// 8. Sync version from root package.json into android/app/build.gradle
console.log('[apply-overlay] Syncing version into build.gradle...');
const rootPkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
const version = rootPkg.version ?? '1.0.0';
const parts = version.split('.').map(Number);
// versionCode: major*10000 + minor*100 + patch (e.g. 4.11.78 -> 41178)
const versionCode = (parts[0] ?? 0) * 10000 + (parts[1] ?? 0) * 100 + (parts[2] ?? 0);
// 8. Sync time-based version into android/app/build.gradle
console.log('[apply-overlay] Syncing time-based version into build.gradle...');
const buildTimestampMs = Date.now();
const versionCode = Math.floor(buildTimestampMs / 1000);
const versionName = new Date(buildTimestampMs).toISOString().replace('T', '.').replaceAll(':', '').replace('Z', '');
const buildGradlePath = join(androidTarget, 'app', 'build.gradle');
let buildGradle = readFileSync(buildGradlePath, 'utf8');
buildGradle = buildGradle
.replace(/versionCode\s+\d+/, `versionCode ${versionCode}`)
.replace(/versionName\s+"[^"]+"/, `versionName "${version}"`);
.replace(/versionName\s+"[^"]+"/, `versionName "${versionName}"`);
writeFileSync(buildGradlePath, buildGradle, 'utf8');
console.log(` versionName "${version}", versionCode ${versionCode}`);
console.log(` versionName "${versionName}", versionCode ${versionCode}`);
// 9. Write android/local.properties with the current machine's Android SDK path
const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;