Compare commits

..

31 Commits

Author SHA1 Message Date
GitHub Actions
9a4bb60942 chore: bump version to 4.11.153 [skip ci] 2026-08-22 22:07:13 +00:00
GitHub Actions
6b35927612 Bump cinny: Add MSC4522 username colors, release notes dialog, and profile layout fixes.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 6m16s
2026-08-22 22:07:05 +00:00
GitHub Actions
aada77f30f chore: bump version to 4.11.152 [skip ci] 2026-08-09 10:49:13 +00:00
bb768911ef Wire Android SSO Custom Tabs, carousel-safe swipes, and core saveMedia.
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m41s
Open SSO in Capacitor Browser with paarrot://android return; stop swipe-to-reply stealing carousel scrolls; bump cinny for shared save helpers and register MediaStore from the overlay.
2026-08-09 20:49:05 +10:00
GitHub Actions
a1c6015772 chore: bump version to 4.11.151 [skip ci] 2026-08-09 09:42:28 +00:00
d7d7d183c5 Stop Android overlay from replacing the image shadowbox.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 5m36s
2026-08-09 19:42:20 +10:00
GitHub Actions
a5ddd10ff4 chore: bump version to 4.11.150 [skip ci] 2026-08-09 08:35:33 +00:00
GitHub Actions
2921d3a547 Bump cinny: Make sick emoji confetti a fullscreen downward spew with morph.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 7m20s
2026-08-09 08:35:27 +00:00
GitHub Actions
2be4c66b4a chore: bump version to 4.11.149 [skip ci] 2026-08-09 05:59:09 +00:00
GitHub Actions
f180f4ab7c Bump cinny: Replace image lightbox with slate-style shadowbox.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 6m48s
2026-08-09 05:59:01 +00:00
GitHub Actions
b1167c3eb6 chore: bump version to 4.11.148 [skip ci] 2026-08-09 05:50:36 +00:00
GitHub Actions
16bdf92f77 Bump cinny: Add fullscreen firework emoji confetti and fix Linux URL paste.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 7m16s
2026-08-09 05:50:28 +00:00
GitHub Actions
2ebe97c5a4 chore: bump version to 4.11.147 [skip ci] 2026-08-09 03:17:01 +00:00
GitHub Actions
1aef7812a1 Bump cinny: Add bot-driven room app UI via im.paarrot.ui state.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 7m55s
2026-08-09 03:16:55 +00:00
GitHub Actions
4f61a31f28 chore: bump version to 4.11.146 [skip ci] 2026-08-03 06:32:44 +00:00
491a5845f0 Trigger builds from PAT submodule bumps and allow manual dispatch.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 5m8s
2026-08-03 16:32:38 +10:00
GitHub Actions
bd25ce8e1d Bump cinny: Use ACTIONS_TOKEN secret to dispatch cinny-mobile builds. 2026-08-03 06:31:00 +00:00
a5d44e191b Fix update-submodule workflow_dispatch input YAML quoting. [skip ci] 2026-08-03 16:30:49 +10:00
5f4bf191fc Add workflow to bump cinny submodule from workflow_dispatch. [skip ci] 2026-08-03 16:28:44 +10:00
GitHub Actions
cfcec87cd9 chore: bump version to 4.11.145 [skip ci] 2026-07-29 10:41:28 +00:00
2c9bcca344 Bump cinny: emoji confetti bursts and component playground.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 5m35s
2026-07-29 20:41:21 +10:00
GitHub Actions
9bb7adafd0 chore: bump version to 4.11.144 [skip ci] 2026-07-27 16:39:51 +00:00
c9c7bdb48d Keep stacked notification images and warn when OEM AUTO_START blocks UnifiedPush.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m35s
Attach image thumbnails via MessagingStyle message data so they survive multi-message history, drop useless image filenames, and surface TCL App Boot blocks in settings.
2026-07-28 02:39:44 +10:00
GitHub Actions
f3d4dcb67e chore: bump version to 4.11.143 [skip ci] 2026-07-27 15:38:12 +00:00
a744e36fb1 Fix UnifiedPush register race where a late UNREGISTER wiped the new endpoint.
All checks were successful
Build / increment-version (push) Successful in 4s
Build / build-android (push) Successful in 5m8s
2026-07-28 01:38:04 +10:00
GitHub Actions
725b3467d3 chore: bump version to 4.11.142 [skip ci] 2026-07-27 14:40:17 +00:00
c28b786948 Show an in-app UnifiedPush distributor picker instead of a silent OS default.
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m42s
2026-07-28 00:40:09 +10:00
GitHub Actions
19d85dbb2d chore: bump version to 4.11.141 [skip ci] 2026-07-27 14:18:44 +00:00
fbff6b9738 Fix UnifiedPush distributor reset and native Matrix gateway discovery.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m35s
2026-07-28 00:18:37 +10:00
GitHub Actions
52e0fddcf1 chore: bump version to 4.11.140 [skip ci] 2026-07-25 10:16:53 +00:00
2ed3b0e9c8 Bump cinny: fix avatar upload for JPEG/WebP/GIF without profile color.
All checks were successful
Build / increment-version (push) Successful in 4s
Build / build-android (push) Successful in 6m37s
2026-07-25 20:16:46 +10:00
27 changed files with 884 additions and 947 deletions

View File

@@ -7,6 +7,7 @@ on:
- dev
tags:
- 'v*'
workflow_dispatch:
jobs:
increment-version:

View File

@@ -0,0 +1,95 @@
name: Update cinny submodule
on:
workflow_dispatch:
inputs:
cinny_sha:
description: "cinny commit SHA to pin (defaults to origin/main)"
required: false
type: string
concurrency:
group: update-cinny-submodule
cancel-in-progress: true
jobs:
bump:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: false
# PAT so the submodule bump push can trigger build.yml
# (GITHUB_TOKEN pushes do not start new workflows).
token: ${{ secrets.ACTIONS_TOKEN }}
ref: master
- name: Update cinny submodule
id: bump
run: |
set -euo pipefail
ORIGIN_URL=$(git remote get-url origin)
ORIGIN_URL=${ORIGIN_URL%/}
ORIGIN_URL=${ORIGIN_URL%.git}
BASE_URL=${ORIGIN_URL%/*}
CINNY_URL="${BASE_URL}/cinny.git"
echo "Rewriting submodule URL to ${CINNY_URL}"
git config submodule.cinny.url "${CINNY_URL}"
git submodule sync --recursive
git submodule update --init --recursive
TARGET_SHA="${{ inputs.cinny_sha }}"
git -C cinny fetch origin main
if [ -n "${TARGET_SHA}" ]; then
git -C cinny checkout --detach "${TARGET_SHA}"
else
git -C cinny checkout --detach origin/main
fi
NEW_SHA=$(git -C cinny rev-parse HEAD)
OLD_SHA=$(git rev-parse HEAD:cinny)
SUBJECT=$(git -C cinny log -1 --pretty=%s)
echo "old=${OLD_SHA}"
echo "new=${NEW_SHA}"
echo "subject=${SUBJECT}"
if [ "${OLD_SHA}" = "${NEW_SHA}" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "cinny already at ${NEW_SHA}; nothing to do"
exit 0
fi
git add cinny
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git commit -m "Bump cinny: ${SUBJECT}"
{
echo "changed=true"
echo "sha=${NEW_SHA}"
} >> "$GITHUB_OUTPUT"
- name: Push submodule bump
if: steps.bump.outputs.changed == 'true'
run: |
set -euo pipefail
for attempt in 1 2 3; do
git fetch origin master
git rebase origin/master || {
git rebase --abort || true
echo "Failed to rebase submodule bump onto latest master"
exit 1
}
if git push origin HEAD:master; then
echo "Pushed cinny bump (${{ steps.bump.outputs.sha }})"
exit 0
fi
echo "Push rejected on attempt ${attempt}, retrying"
done
echo "Failed to push submodule bump after 3 attempts"
exit 1

View File

@@ -9,7 +9,10 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-status-bar')
}

View File

@@ -40,6 +40,14 @@
<data android:mimeType="*/*" />
</intent-filter>
<!-- SSO / deep link return: paarrot://android/login/...?loginToken=... -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="paarrot" />
</intent-filter>
</activity>
<provider
@@ -87,4 +95,22 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Android 11+ package visibility for UnifiedPush distributors (ntfy, etc.) -->
<queries>
<intent>
<action android:name="org.unifiedpush.android.distributor.REGISTER" />
</intent>
<intent>
<action android:name="org.unifiedpush.android.distributor.UNREGISTER" />
</intent>
<intent>
<action android:name="org.unifiedpush.android.distributor.LINK" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="unifiedpush" />
</intent>
<package android:name="io.heckel.ntfy" />
</queries>
</manifest>

View File

@@ -21,6 +21,7 @@ import android.graphics.PorterDuffXfermode
import android.graphics.Rect
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.content.FileProvider
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
@@ -33,6 +34,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
@@ -274,19 +277,17 @@ class MatrixSyncService : Service() {
val body = when {
eventType == "m.room.encrypted" -> "Encrypted message"
eventType == "m.sticker" -> {
val raw = content.optString("body")
if (raw.isNotBlank()) "🖼️ $raw" else "🖼️ Sticker"
}
eventType == "m.sticker" -> "🖼️ Sticker"
else -> {
val msgtype = content.optString("msgtype")
val rawBody = content.optString("body")
when (msgtype) {
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo"
// Skip Matrix body for images/stickers — it's usually a useless filename.
"m.image" -> "📷 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"
"m.sticker" -> "🖼️ Sticker"
else -> rawBody.takeIf { it.isNotBlank() }
}
}
@@ -836,10 +837,56 @@ class MatrixSyncService : Service() {
val sender: String,
val text: String,
val timestamp: Long,
/** content:// URI for MessagingStyle image data, if any. */
val imageUri: String? = null,
)
private fun historyPrefsKey(roomId: String) = KEY_MSG_HISTORY_PREFIX + roomId
private fun notifImageDir(context: Context): File =
File(context.cacheDir, "notif-images").apply { mkdirs() }
/** Write [bitmap] to cache and return a FileProvider URI SystemUI can read. */
private fun persistNotificationImage(context: Context, bitmap: Bitmap): Uri? {
return try {
val file = File(notifImageDir(context), "img_${System.currentTimeMillis()}.jpg")
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out)
}
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
)
// SystemUI reads MessagingStyle image URIs; grant explicitly on OEM builds.
runCatching {
context.grantUriPermission(
"com.android.systemui",
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
}
uri
} catch (e: Exception) {
Log.w(TAG, "Failed to persist notification image: ${e.message}")
null
}
}
private fun deleteNotificationImageUri(context: Context, uriString: String?) {
if (uriString.isNullOrBlank()) return
try {
val uri = Uri.parse(uriString)
val path = uri.path ?: return
// FileProvider paths look like /my_cache_images/notif-images/...
val name = path.substringAfterLast('/')
if (name.isBlank()) return
File(notifImageDir(context), name).delete()
} catch (e: Exception) {
Log.w(TAG, "Failed to delete notification image: ${e.message}")
}
}
private fun loadMessageHistory(context: Context, roomId: String): List<PendingNotifMessage> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(historyPrefsKey(roomId), null)
@@ -852,8 +899,9 @@ class MatrixSyncService : Service() {
val sender = obj.optString("sender")
val text = obj.optString("text")
val ts = obj.optLong("ts", 0L)
val imageUri = obj.optString("imageUri").takeIf { it.isNotBlank() }
if (sender.isBlank() || text.isBlank()) continue
add(PendingNotifMessage(sender, text, ts))
add(PendingNotifMessage(sender, text, ts, imageUri))
}
}
} catch (e: Exception) {
@@ -869,12 +917,14 @@ class MatrixSyncService : Service() {
) {
val arr = JSONArray()
for (msg in messages) {
arr.put(
JSONObject()
.put("sender", msg.sender)
.put("text", msg.text)
.put("ts", msg.timestamp)
)
val obj = JSONObject()
.put("sender", msg.sender)
.put("text", msg.text)
.put("ts", msg.timestamp)
if (!msg.imageUri.isNullOrBlank()) {
obj.put("imageUri", msg.imageUri)
}
arr.put(obj)
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
@@ -883,6 +933,9 @@ class MatrixSyncService : Service() {
}
private fun clearMessageHistory(context: Context, roomId: String) {
for (msg in loadMessageHistory(context, roomId)) {
deleteNotificationImageUri(context, msg.imageUri)
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.remove(historyPrefsKey(roomId))
@@ -894,12 +947,19 @@ class MatrixSyncService : Service() {
roomId: String,
sender: String,
text: String,
imageUri: String? = null,
): List<PendingNotifMessage> {
val next = (loadMessageHistory(context, roomId) + PendingNotifMessage(
val previous = loadMessageHistory(context, roomId)
val next = (previous + PendingNotifMessage(
sender = sender,
text = text,
timestamp = System.currentTimeMillis(),
imageUri = imageUri,
)).takeLast(MAX_MESSAGING_HISTORY)
// Drop image files for messages that fell out of the sliding window.
for (dropped in previous - next.toSet()) {
deleteNotificationImageUri(context, dropped.imageUri)
}
saveMessageHistory(context, roomId, next)
return next
}
@@ -967,7 +1027,14 @@ class MatrixSyncService : Service() {
}
val senderPerson = personBuilder.build()
val history = appendMessageHistory(context, roomId, resolvedSender, resolvedMessage)
val imageUri = inlineImage?.let { persistNotificationImage(context, it) }?.toString()
val history = appendMessageHistory(
context,
roomId,
resolvedSender,
resolvedMessage,
imageUri,
)
val channelId = channelIdForKindStatic(kind)
val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) {
@@ -1008,13 +1075,17 @@ class MatrixSyncService : Service() {
.setImportant(true)
.build()
}
messagingStyle.addMessage(
NotificationCompat.MessagingStyle.Message(
msg.text,
msg.timestamp,
person,
)
val line = NotificationCompat.MessagingStyle.Message(
msg.text,
msg.timestamp,
person,
)
// Attach image on the message itself so multi-message history keeps thumbnails
// (BigPictureStyle replaces MessagingStyle and culls prior lines).
if (!msg.imageUri.isNullOrBlank()) {
line.setData("image/jpeg", Uri.parse(msg.imageUri))
}
messagingStyle.addMessage(line)
}
val builder = NotificationCompat.Builder(context, channelId)
@@ -1044,17 +1115,6 @@ class MatrixSyncService : Service() {
// Collapsed shade avatar (large icon). Small icon must stay the monochrome app mark.
if (avatar != null) builder.setLargeIcon(avatar)
// Image attachments: keep MessagingStyle history; BigPicture would wipe prior lines.
if (inlineImage != null && history.size == 1) {
builder.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage)
.bigLargeIcon(avatar)
.setBigContentTitle(collapsedTitle)
.setSummaryText(collapsedText)
)
}
nm.notify(roomNotifId, builder.build())
val summaryId = notificationIdForGroupSummary(groupId)

View File

@@ -17,6 +17,8 @@ import com.getcapacitor.annotation.CapacitorPlugin
* - `stop()` — clear persisted credentials and unregister UnifiedPush
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
* - `getStatus()` — returns current fetch and UnifiedPush state
* - `requestDistributorSetup()` — clear saved distributor and re-open OS picker
* - `resolveUnifiedPushGateway({ endpoint })` — native Matrix gateway discovery
* - `clearRoomNotifications({ roomId })` — dismiss native tray notifs for a room
* - `setNotificationGroups({ rooms })` — persist space/DM grouping for tray nesting
* - `showNotification({ title, body, roomId, groupId, groupName, kind, largeIconBase64 })`
@@ -131,6 +133,35 @@ class SyncServicePlugin : Plugin() {
call.resolve(result)
}
/**
* Clears the saved UnifiedPush distributor and opens the OS default-distributor
* picker (`unifiedpush://link`), then re-registers on success.
*/
@PluginMethod
fun requestDistributorSetup(call: PluginCall) {
val currentActivity = activity
?: return call.reject("Activity required to select a UnifiedPush distributor")
UnifiedPushManager.requestDistributorSetup(context, currentActivity) { success ->
call.resolve(JSObject().put("success", success))
}
}
/**
* Resolves the Matrix push gateway for a UnifiedPush endpoint using native HTTP
* so discovery is not blocked by missing CORS headers in the WebView.
*/
@PluginMethod
fun resolveUnifiedPushGateway(call: PluginCall) {
val endpoint = call.getString("endpoint")
?: return call.reject("endpoint required")
Thread {
val gateway = UnifiedPushManager.resolveMatrixGateway(endpoint)
bridge?.executeOnMainThread {
call.resolve(JSObject().put("gatewayUrl", gateway))
}
}.start()
}
/**
* Cancels native tray notifications for a Matrix room after it has been marked as read.
*/

View File

@@ -1,12 +1,24 @@
package com.paarrot.app
import android.app.Activity
import android.app.AlertDialog
import android.app.AppOpsManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.os.SystemClock
import android.util.Log
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import org.json.JSONObject
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.INSTANCE_DEFAULT
import org.unifiedpush.android.connector.UnifiedPush
import java.net.HttpURLConnection
import java.net.URL
/** Coordinates UnifiedPush registration state and bridges events back to JS. */
object UnifiedPushManager {
@@ -14,14 +26,26 @@ object UnifiedPushManager {
private const val PREFS = "unifiedpush_prefs"
private const val KEY_ENDPOINT = "endpoint"
private const val KEY_INSTANCE = "instance"
private const val KEY_LAST_FAILURE = "last_failure"
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"
private const val REGISTER_DELAY_MS = 750L
private const val IGNORE_UNREGISTER_MS = 2_500L
const val DEFAULT_MATRIX_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile
private var plugin: SyncServicePlugin? = null
@Volatile
private var setupGeneration = 0
@Volatile
private var ignoreUnregisteredUntilElapsed = 0L
fun setPlugin(plugin: SyncServicePlugin) {
this.plugin = plugin
}
@@ -44,15 +68,183 @@ object UnifiedPushManager {
return
}
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success ->
if (success) {
requestRegistration(context)
} else {
// Prefer an explicit in-app picker so the user always sees something;
// tryUseDefaultDistributor is silent when only one distributor is installed.
requestDistributorSetup(context, activity) { success ->
if (!success) {
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
}
}
}
/**
* Shows an in-app distributor picker, then registers after a short delay.
*
* Important: we intentionally avoid calling [UnifiedPush.removeDistributor] before the
* user picks. That sent an UNREGISTER that could arrive *after* the new REGISTER and
* wipe the fresh endpoint (looked like "found ntfy but never connects").
*/
fun requestDistributorSetup(context: Context, activity: Activity, onDone: (Boolean) -> Unit) {
val gen = ++setupGeneration
ignoreUnregisteredUntilElapsed = SystemClock.elapsedRealtime() + IGNORE_UNREGISTER_MS
clearEndpoint(context)
clearLastFailure(context)
val distributors = runCatching { UnifiedPush.getDistributors(context) }
.getOrDefault(emptyList())
Log.i(TAG, "Distributor setup: found=${distributors.joinToString()}")
if (distributors.isEmpty()) {
Log.w(TAG, "No UnifiedPush distributors installed")
persistLastFailure(context, "NO_DISTRIBUTOR")
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
onDone(false)
return
}
val labels = distributors.map { packageName ->
distributorLabel(context, packageName)
}.toTypedArray()
// Guard against cancel + dismiss double-firing the callback.
val finished = java.util.concurrent.atomic.AtomicBoolean(false)
val finish: (Boolean) -> Unit = { success ->
if (finished.compareAndSet(false, true)) onDone(success)
}
activity.runOnUiThread {
AlertDialog.Builder(activity)
.setTitle("Choose push distributor")
.setItems(labels) { _, which ->
val chosen = distributors.getOrNull(which)
if (chosen.isNullOrBlank()) {
finish(false)
return@setItems
}
activateDistributor(context, chosen, gen, finish)
}
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) }
.setOnCancelListener { finish(false) }
.show()
}
}
/**
* Saves [chosen] as the distributor and registers after a delay so any prior
* UNREGISTER from a distributor switch can settle first.
*/
private fun activateDistributor(
context: Context,
chosen: String,
gen: Int,
finish: (Boolean) -> Unit,
) {
ignoreUnregisteredUntilElapsed = SystemClock.elapsedRealtime() + IGNORE_UNREGISTER_MS
val previous = runCatching { UnifiedPush.getSavedDistributor(context) }.getOrNull().orEmpty()
Log.i(TAG, "Activating distributor=$chosen previous=$previous")
if (previous.isNotBlank() && previous != chosen) {
runCatching { UnifiedPush.removeDistributor(context) }
.onFailure { Log.w(TAG, "removeDistributor failed: ${it.message}") }
}
mainHandler.postDelayed({
if (gen != setupGeneration) {
Log.w(TAG, "Skipping stale distributor activation gen=$gen current=$setupGeneration")
finish(false)
return@postDelayed
}
runCatching { UnifiedPush.saveDistributor(context, chosen) }
.onFailure {
Log.w(TAG, "saveDistributor failed: ${it.message}")
persistLastFailure(context, "SAVE_FAILED")
finish(false)
return@postDelayed
}
val saved = runCatching { UnifiedPush.getSavedDistributor(context) }.getOrNull()
if (saved != chosen) {
Log.e(TAG, "Distributor not saved after saveDistributor (got=$saved)")
persistLastFailure(context, "SAVE_NOT_PERSISTED")
finish(false)
return@postDelayed
}
requestRegistration(context)
// Report true once register was requested; JS polls for the endpoint.
finish(true)
// One retry if the distributor is slow to ack.
mainHandler.postDelayed({
if (gen != setupGeneration) return@postDelayed
if (!getEndpoint(context).isNullOrBlank()) return@postDelayed
Log.w(TAG, "No endpoint yet after ${REGISTER_DELAY_MS}ms — retrying register")
requestRegistration(context)
// OEM "App Boot" / AUTO_START can silently drop NEW_ENDPOINT (seen on TCL).
mainHandler.postDelayed({
if (gen != setupGeneration) return@postDelayed
if (!getEndpoint(context).isNullOrBlank()) return@postDelayed
val reason = if (isAutoStartRestricted(context)) {
"AUTO_START_BLOCKED"
} else {
"NO_ENDPOINT"
}
Log.w(TAG, "Still no endpoint after register retries — $reason")
persistLastFailure(context, reason)
dispatchRegistrationFailed(reason, INSTANCE_DEFAULT)
}, 4_000L)
}, REGISTER_DELAY_MS)
}, if (previous.isNotBlank() && previous != chosen) REGISTER_DELAY_MS else 150L)
}
/** Human-readable label for a distributor package (falls back to package name). */
private fun distributorLabel(context: Context, packageName: String): String {
return try {
val pm = context.packageManager
val appInfo = pm.getApplicationInfo(packageName, 0)
val label = pm.getApplicationLabel(appInfo)?.toString().orEmpty()
if (label.isNotBlank() && label != packageName) "$label ($packageName)" else packageName
} catch (_: PackageManager.NameNotFoundException) {
packageName
}
}
/**
* Discovers the Matrix push gateway for a UnifiedPush endpoint via native HTTP
* (WebView `fetch` fails — ntfy responses omit CORS headers).
*/
fun resolveMatrixGateway(endpoint: String): String {
return try {
val endpointUrl = URL(endpoint)
val discoveryUrl = URL(
endpointUrl.protocol,
endpointUrl.host,
endpointUrl.port,
"/_matrix/push/v1/notify",
)
val conn = discoveryUrl.openConnection() as HttpURLConnection
try {
conn.requestMethod = "GET"
conn.connectTimeout = 4_000
conn.readTimeout = 6_000
conn.setRequestProperty("Accept", "application/json")
if (conn.responseCode != 200) return DEFAULT_MATRIX_GATEWAY
val body = JSONObject(conn.inputStream.bufferedReader().readText())
val gateway =
body.optString("gateway").takeIf { it.isNotBlank() }
?: body.optJSONObject("unifiedpush")?.optString("gateway")
if (gateway == "matrix") discoveryUrl.toString() else DEFAULT_MATRIX_GATEWAY
} finally {
conn.disconnect()
}
} catch (e: Exception) {
Log.w(TAG, "Matrix gateway discovery failed: ${e.message}")
DEFAULT_MATRIX_GATEWAY
}
}
fun unregister(context: Context) {
runCatching {
UnifiedPush.unregister(context, INSTANCE_DEFAULT)
@@ -70,14 +262,39 @@ object UnifiedPushManager {
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>())
)
status.put("lastFailure", getLastFailure(context) ?: "")
status.put("autoStartBlocked", isAutoStartRestricted(context))
val distributors = JSArray()
runCatching { UnifiedPush.getDistributors(context) }
.getOrDefault(emptyList())
.forEach { distributors.put(it) }
status.put("distributors", distributors)
return status
}
/**
* TCL / some OEMs expose an AUTO_START app-op. When it is not MODE_ALLOWED,
* [AppBootManager] drops distributor broadcasts such as NEW_ENDPOINT, so
* UnifiedPush never finishes registering even though ntfy is selected.
*/
private fun isAutoStartRestricted(context: Context): Boolean {
return try {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
appOps.unsafeCheckOpNoThrow("android:auto_start", Process.myUid(), context.packageName)
} else {
@Suppress("DEPRECATION")
appOps.checkOpNoThrow("android:auto_start", Process.myUid(), context.packageName)
}
mode != AppOpsManager.MODE_ALLOWED && mode != AppOpsManager.MODE_DEFAULT
} catch (_: Exception) {
false
}
}
fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
Log.i(TAG, "onNewEndpoint instance=$instance endpoint=$endpoint")
clearLastFailure(context)
val previousEndpoint = getEndpoint(context)
persistEndpoint(context, endpoint, instance)
val payload = JSObject().apply {
@@ -88,11 +305,18 @@ object UnifiedPushManager {
dispatch(EVENT_NEW_ENDPOINT, payload)
}
fun onRegistrationFailed(reason: String, instance: String) {
fun onRegistrationFailed(context: Context, reason: String, instance: String) {
Log.w(TAG, "onRegistrationFailed reason=$reason instance=$instance")
persistLastFailure(context, reason)
dispatchRegistrationFailed(reason, instance)
}
fun onUnregistered(context: Context, instance: String) {
if (SystemClock.elapsedRealtime() < ignoreUnregisteredUntilElapsed) {
Log.i(TAG, "Ignoring unregistered during setup window instance=$instance")
return
}
Log.i(TAG, "onUnregistered instance=$instance")
val previousEndpoint = getEndpoint(context)
clearEndpoint(context)
dispatchUnregistered(previousEndpoint, instance)
@@ -100,9 +324,11 @@ object UnifiedPushManager {
private fun requestRegistration(context: Context) {
runCatching {
Log.i(TAG, "Calling UnifiedPush.register instance=$INSTANCE_DEFAULT")
UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null)
}.onFailure {
Log.w(TAG, "UnifiedPush register failed: ${it.message}")
persistLastFailure(context, FailedReason.INTERNAL_ERROR.name)
dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT)
}
}
@@ -141,6 +367,21 @@ object UnifiedPushManager {
.apply()
}
private fun persistLastFailure(context: Context, reason: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY_LAST_FAILURE, reason)
.apply()
}
private fun clearLastFailure(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.remove(KEY_LAST_FAILURE)
.apply()
}
private fun getLastFailure(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_LAST_FAILURE, null)
private fun getEndpoint(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null)

View File

@@ -21,7 +21,7 @@ class UnifiedPushService : PushService() {
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
Log.w(TAG, "UnifiedPush registration failed: $reason")
UnifiedPushManager.onRegistrationFailed(reason.name, instance)
UnifiedPushManager.onRegistrationFailed(applicationContext, reason.name, instance)
}
override fun onUnregistered(instance: String) {

View File

@@ -2,5 +2,14 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-browser'
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')

2
cinny

Submodule cinny updated: b52926f7d8...e65a516350

View File

@@ -1,5 +1,7 @@
{
"dependencies": {
"@capacitor/app": "8.0.1",
"@capacitor/browser": "8.0.1",
"@capacitor/status-bar": "8.0.1"
}
}

View File

@@ -1,251 +0,0 @@
/* eslint-disable no-param-reassign */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
import { Icon, Icons } from '../icons';
import FocusTrap from 'focus-trap-react';
import { saveMediaBlob } from '../../utils/saveMedia';
import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom';
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
import { stopPropagation } from '../../utils/keyboard';
export type PdfViewerProps = {
name: string;
src: string;
requestClose: () => void;
};
export const PdfViewer = as<'div', PdfViewerProps>(
({ className, name, src, requestClose, ...props }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
const [docState, loadPdfDocument] = usePdfDocumentLoader(
pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined,
src
);
const isLoading =
pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading;
const isError =
pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
const [pageNo, setPageNo] = useState(1);
const [jumpAnchor, setJumpAnchor] = useState<RectCords>();
useEffect(() => {
loadPdfJS();
}, [loadPdfJS]);
useEffect(() => {
if (pdfJSState.status === AsyncStatus.Success) {
loadPdfDocument();
}
}, [pdfJSState, loadPdfDocument]);
useEffect(() => {
if (docState.status === AsyncStatus.Success) {
const doc = docState.data;
if (pageNo < 0 || pageNo > doc.numPages) return;
createPage(doc, pageNo, { scale: zoom }).then((canvas) => {
const container = containerRef.current;
if (!container) return;
container.textContent = '';
container.append(canvas);
scrollRef.current?.scrollTo({
top: 0,
});
});
}
}, [docState, pageNo, zoom]);
const handleDownload = async () => {
try {
const res = await fetch(src);
if (!res.ok) throw new Error('Failed to fetch PDF');
await saveMediaBlob(await res.blob(), name);
} catch (error) {
console.warn('[PdfViewer] Failed to download:', error);
}
};
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (docState.status !== AsyncStatus.Success) return;
const jumpInput = evt.currentTarget.jumpInput as HTMLInputElement;
if (!jumpInput) return;
const jumpTo = parseInt(jumpInput.value, 10);
setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
setJumpAnchor(undefined);
};
const handlePrevPage = () => {
setPageNo((n) => Math.max(n - 1, 1));
};
const handleNextPage = () => {
if (docState.status !== AsyncStatus.Success) return;
setPageNo((n) => Math.min(n + 1, docState.data.numPages));
};
const handleOpenJump: MouseEventHandler<HTMLButtonElement> = (evt) => {
setJumpAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
<Header className={css.PdfViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
{name}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
radii="Pill"
onClick={zoomOut}
aria-label="Zoom Out"
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"
radii="Pill"
onClick={zoomIn}
aria-label="Zoom In"
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
<Chip
variant="Primary"
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
{isLoading && <Spinner variant="Secondary" size="600" />}
{isError && (
<>
<Text>Failed to load PDF</Text>
<Button
variant="Critical"
fill="Soft"
size="300"
radii="300"
before={<Icon src={Icons.Warning} size="50" />}
onClick={loadPdfJS}
>
<Text size="B300">Retry</Text>
</Button>
</>
)}
{docState.status === AsyncStatus.Success && (
<Scroll
ref={scrollRef}
size="300"
direction="Both"
variant="Surface"
visibility="Hover"
>
<Box>
<div className={css.PdfViewerContent} ref={containerRef} />
</Box>
</Scroll>
)}
</Box>
{docState.status === AsyncStatus.Success && (
<Header as="footer" className={css.PdfViewerFooter} size="400">
<Chip
variant="Secondary"
radii="300"
before={<Icon size="50" src={Icons.ChevronLeft} />}
onClick={handlePrevPage}
aria-disabled={pageNo <= 1}
>
<Text size="B300">Previous</Text>
</Chip>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<PopOut
anchor={jumpAnchor}
align="Center"
position="Top"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setJumpAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu variant="Surface">
<Box
as="form"
onSubmit={handleJumpSubmit}
style={{ padding: config.space.S200 }}
direction="Column"
gap="200"
>
<Input
name="jumpInput"
size="300"
variant="Background"
defaultValue={pageNo}
min={1}
max={docState.data.numPages}
step={1}
outlined
type="number"
radii="300"
aria-label="Page Number"
/>
<Button type="submit" size="300" variant="Primary" radii="300">
<Text size="B300">Jump To Page</Text>
</Button>
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
onClick={handleOpenJump}
variant="SurfaceVariant"
radii="300"
aria-pressed={jumpAnchor !== undefined}
>
<Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
</Chip>
</PopOut>
</Box>
<Chip
variant="Primary"
radii="300"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={handleNextPage}
aria-disabled={pageNo >= docState.data.numPages}
>
<Text size="B300">Next</Text>
</Chip>
</Header>
)}
</Box>
);
}
);

View File

@@ -1,107 +0,0 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React from 'react';
import classNames from 'classnames';
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
import { Icon, Icons } from '../icons';
import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadAndSaveMedia } from '../../utils/saveMedia';
import { getCurrentAccessToken } from '../../utils/auth';
export type ImageViewerProps = {
alt: string;
src: string;
requestClose: () => void;
};
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = async () => {
try {
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
} catch (error) {
console.warn('[ImageViewer] Failed to download media:', error);
try {
window.open(src, '_blank');
} catch {
// ignore
}
}
};
return (
<Box
className={classNames(css.ImageViewer, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.ImageViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
{alt}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
radii="Pill"
onClick={zoomOut}
aria-label="Zoom Out"
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"
radii="Pill"
onClick={zoomIn}
aria-label="Zoom In"
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
<Chip
variant="Primary"
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
className={css.ImageViewerContent}
justifyContent="Center"
alignItems="Center"
>
<img
className={css.ImageViewerImg}
style={{
cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
}}
src={src}
alt={alt}
draggable={false}
onMouseDown={onMouseDown}
/>
</Box>
</Box>
);
}
);

View File

@@ -1,84 +0,0 @@
import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds';
import { Icon, Icons } from '../icons';
import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../utils/matrix';
import { getCurrentAccessToken } from '../../utils/auth';
import { saveMediaBlob } from '../../utils/saveMedia';
const badgeStyles = { maxWidth: toRem(100) };
type FileDownloadButtonProps = {
filename: string;
url: string;
mimeType: string;
encInfo?: EncryptedAttachmentInfo;
};
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
await saveMediaBlob(fileContent, filename);
return true;
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
);
const downloading = downloadState.status === AsyncStatus.Loading;
const hasError = downloadState.status === AsyncStatus.Error;
return (
<IconButton
disabled={downloading}
onClick={download}
variant={hasError ? 'Critical' : 'SurfaceVariant'}
size="300"
radii="300"
>
{downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
) : (
<Icon size="100" src={Icons.Download} />
)}
</IconButton>
);
}
export type FileHeaderProps = {
body: string;
mimeType: string;
after?: ReactNode;
};
export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => (
<Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
<Box shrink="No">
<Badge style={badgeStyles} variant="Secondary" radii="Pill">
<Text size="O400" truncate>
{mimeTypeToExt(mimeType)}
</Text>
</Badge>
</Box>
<Box grow="Yes">
<Text size="T300" truncate>
{body}
</Text>
</Box>
{after}
</Box>
));

View File

@@ -1,303 +0,0 @@
import React, { ReactNode, useCallback, useState } from 'react';
import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Icon, Icons } from '../../icons';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { saveMediaBlob } from '../../../utils/saveMedia';
import FocusTrap from 'focus-trap-react';
import { IFileInfo } from '../../../../types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { bytesToSize } from '../../../utils/common';
import {
READABLE_EXT_TO_MIME_TYPE,
READABLE_TEXT_MIME_TYPES,
getFileNameExt,
mimeTypeToExt,
} from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getCurrentAccessToken } from '../../../utils/auth';
import { ModalWide } from '../../../styles/Modal.css';
const renderErrorButton = (retry: () => void, text: string) => (
<TooltipProvider
tooltip={
<Tooltip variant="Critical">
<Text>Failed to load file!</Text>
</Tooltip>
}
position="Top"
align="Center"
>
{(triggerRef) => (
<Button
ref={triggerRef}
size="400"
variant="Critical"
fill="Soft"
outlined
radii="300"
onClick={retry}
before={<Icon size="100" src={Icons.Warning} filled />}
>
<Text size="B400" truncate>
{text}
</Text>
</Button>
)}
</TooltipProvider>
);
type RenderTextViewerProps = {
name: string;
text: string;
langName: string;
requestClose: () => void;
};
type ReadTextFileProps = {
body: string;
mimeType: string;
url: string;
encInfo?: EncryptedAttachmentInfo;
renderViewer: (props: RenderTextViewerProps) => ReactNode;
};
export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [textViewer, setTextViewer] = useState(false);
const [textState, loadText] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
const text = fileContent.text();
setTextViewer(true);
return text;
}, [mx, useAuthentication, mimeType, encInfo, url])
);
return (
<>
{textState.status === AsyncStatus.Success && (
<Overlay open={textViewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setTextViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
name: body,
text: textState.data,
langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
? mimeTypeToExt(mimeType)
: mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
requestClose: () => setTextViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{textState.status === AsyncStatus.Error ? (
renderErrorButton(loadText, 'Open File')
) : (
<Button
variant="Secondary"
fill="Solid"
radii="300"
size="400"
onClick={() =>
textState.status === AsyncStatus.Success ? setTextViewer(true) : loadText()
}
disabled={textState.status === AsyncStatus.Loading}
before={
textState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" size="100" variant="Secondary" />
) : (
<Icon size="100" src={Icons.ArrowRight} filled />
)
}
>
<Text size="B400" truncate>
Open File
</Text>
</Button>
)}
</>
);
}
type RenderPdfViewerProps = {
name: string;
src: string;
requestClose: () => void;
};
export type ReadPdfFileProps = {
body: string;
mimeType: string;
url: string;
encInfo?: EncryptedAttachmentInfo;
renderViewer: (props: RenderPdfViewerProps) => ReactNode;
};
export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [pdfViewer, setPdfViewer] = useState(false);
const [pdfState, loadPdf] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
setPdfViewer(true);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
);
return (
<>
{pdfState.status === AsyncStatus.Success && (
<Overlay open={pdfViewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setPdfViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
name: body,
src: pdfState.data,
requestClose: () => setPdfViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{pdfState.status === AsyncStatus.Error ? (
renderErrorButton(loadPdf, 'Open PDF')
) : (
<Button
variant="Secondary"
fill="Solid"
radii="300"
size="400"
onClick={() => (pdfState.status === AsyncStatus.Success ? setPdfViewer(true) : loadPdf())}
disabled={pdfState.status === AsyncStatus.Loading}
before={
pdfState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" size="100" variant="Secondary" />
) : (
<Icon size="100" src={Icons.ArrowRight} filled />
)
}
>
<Text size="B400" truncate>
Open PDF
</Text>
</Button>
)}
</>
);
}
export type DownloadFileProps = {
body: string;
mimeType: string;
url: string;
info: IFileInfo;
encInfo?: EncryptedAttachmentInfo;
};
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
await saveMediaBlob(fileContent, body);
return fileContent;
}, [mx, url, useAuthentication, mimeType, encInfo, body])
);
return downloadState.status === AsyncStatus.Error ? (
renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
) : (
<Button
variant="Secondary"
fill="Soft"
radii="300"
size="400"
onClick={() =>
downloadState.status === AsyncStatus.Success
? saveMediaBlob(downloadState.data, body)
: download()
}
disabled={downloadState.status === AsyncStatus.Loading}
before={
downloadState.status === AsyncStatus.Loading ? (
<Spinner fill="Soft" size="100" variant="Secondary" />
) : (
<Icon size="100" src={Icons.Download} filled />
)
}
>
<Text size="B400" truncate>{`Download (${bytesToSize(info.size ?? 0)})`}</Text>
</Button>
);
}
type FileContentProps = {
body: string;
mimeType: string;
renderAsTextFile: () => ReactNode;
renderAsPdfFile: () => ReactNode;
};
export const FileContent = as<'div', FileContentProps>(
({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => (
<Box direction="Column" gap="300" {...props} ref={ref}>
{(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) &&
renderAsTextFile()}
{mimeType === 'application/pdf' && renderAsPdfFile()}
{children}
</Box>
)
);

View File

@@ -45,22 +45,42 @@ type MobileSwipeGestureHostProps = {
children: ReactNode;
};
const IGNORE_BACK_SELECTOR =
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]';
const IGNORE_REPLY_SELECTOR =
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]';
/** True when the touch starts on (or inside) a horizontally scrollable region like an image carousel. */
const isInsideHorizontalScroller = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return false;
let el: Element | null = target;
while (el && el !== document.documentElement) {
if (el instanceof HTMLElement) {
const style = window.getComputedStyle(el);
const overflowX = style.overflowX;
if (
(overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'overlay') &&
el.scrollWidth > el.clientWidth + 1
) {
return true;
}
}
el = el.parentElement;
}
return false;
};
const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
)
);
return Boolean(target.closest(IGNORE_BACK_SELECTOR)) || isInsideHorizontalScroller(target);
};
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
)
);
return Boolean(target.closest(IGNORE_REPLY_SELECTOR)) || isInsideHorizontalScroller(target);
};
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {

View File

@@ -1,74 +0,0 @@
import React from 'react';
import classNames from 'classnames';
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
import { Icon, Icons } from '../icons';
import * as css from './VideoViewer.css';
import { downloadAndSaveMedia } from '../../utils/saveMedia';
import { getCurrentAccessToken } from '../../utils/auth';
export type VideoViewerProps = {
alt: string;
src: string;
requestClose: () => void;
};
export const VideoViewer = as<'div', VideoViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const handleDownload = async () => {
try {
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
} catch (error) {
console.warn('[VideoViewer] Failed to download media:', error);
try {
window.open(src, '_blank');
} catch {
// ignore
}
}
};
return (
<Box
className={classNames(css.VideoViewer, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.VideoViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
{alt}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<Chip
variant="Primary"
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
className={css.VideoViewerContent}
justifyContent="Center"
alignItems="Center"
>
<video
className={css.VideoViewerVideo}
src={src}
title={alt}
controls
autoPlay
/>
</Box>
</Box>
);
}
);

View File

@@ -170,22 +170,34 @@ type PushStatus = {
registered: boolean;
distributor: string;
endpoint: string;
distributors: string[];
lastFailure: string;
autoStartBlocked: boolean;
};
/** Android-only section showing UnifiedPush registration status and controls. */
function AndroidPushNotifications() {
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [lastError, setLastError] = useState<string | undefined>(undefined);
const refresh = useCallback(async () => {
setLoading(true);
const s = await getBackgroundSyncStatus();
const distributors = Array.isArray(s?.distributors)
? s.distributors
: typeof s?.distributors === 'string' && s.distributors && s.distributors !== '[]'
? [s.distributors]
: [];
setStatus(
s
? {
registered: s.registered,
distributor: s.distributor || '',
endpoint: s.endpoint || '',
distributors,
lastFailure: s.lastFailure || '',
autoStartBlocked: Boolean(s.autoStartBlocked),
}
: undefined
);
@@ -198,8 +210,14 @@ function AndroidPushNotifications() {
const [resetState, reset] = useAsyncCallback(
useCallback(async () => {
await requestResetPushRegistration();
setLastError(undefined);
const result = await requestResetPushRegistration();
await refresh();
if (!result.success) {
setLastError(
'Selected distributor but did not get a push endpoint. In ntfy: enable UnifiedPush, allow unrestricted battery, then try Reset again.'
);
}
}, [refresh])
);
@@ -214,6 +232,50 @@ function AndroidPushNotifications() {
resetState.status === AsyncStatus.Loading ||
pingState.status === AsyncStatus.Loading;
const statusDescription = (() => {
if (loading) return 'Loading status…';
if (status === undefined) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Failed to read push status.
</Text>
);
}
if (status.registered) {
return (
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
</Text>
);
}
if (status.lastFailure === 'AUTO_START_BLOCKED' || status.autoStartBlocked) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Phone is blocking auto-start for Paarrot. Open App Boot / Auto-start settings, allow Paarrot (and ntfy), then tap Reset.
</Text>
);
}
if (status.lastFailure) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{`Registration failed (${status.lastFailure}). Tap Reset and pick ntfy again.`}
</Text>
);
}
if (status.distributors.length === 0) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
No UnifiedPush distributor found. Install ntfy from Play Store/F-Droid and enable UnifiedPush in ntfy settings.
</Text>
);
}
return (
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
{`Found ${status.distributors.join(', ')} but not registered yet. Tap Reset and pick ntfy.`}
</Text>
);
})();
return (
<Box direction="Column" gap="100">
<Text size="L400">Android Push (UnifiedPush)</Text>
@@ -226,29 +288,20 @@ function AndroidPushNotifications() {
<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'}`}
<>
{statusDescription}
{lastError ? (
<Text as="span" style={{ color: color.Critical.Main, display: 'block' }} size="T200">
{lastError}
</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>
)
) : null}
</>
}
after={loading ? <Spinner variant="Secondary" /> : undefined}
/>
<SettingTile
title="Change Distributor"
description="Re-open the UnifiedPush distributor selection dialog."
description="Shows a list of installed UnifiedPush apps (ntfy, etc.) and registers the one you pick."
after={
<Button
size="300"

View File

@@ -0,0 +1,49 @@
import { useMemo } from 'react';
import { useClientConfig } from './useClientConfig';
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
import { isCapacitorNative, isElectron } from '../utils/tauri';
/** Authority shown on Matrix SSO consent ("Continue to paarrot://desktop"). */
export const ELECTRON_PROTOCOL_HOST = 'desktop';
/** Authority shown on Matrix SSO consent for the Android Capacitor app. */
export const ANDROID_PROTOCOL_HOST = 'android';
/**
* Build an absolute URL for the given in-app path.
*
* In Electron / Capacitor, SSO (and other browser handoffs) must return via
* paarrot://{host}/... — a named host so consent UI identifies the app, and a
* real pathname (not a hash) because browsers strip fragments when launching
* custom protocols.
*/
export const usePathWithOrigin = (path: string): string => {
const { hashRouter } = useClientConfig();
const { origin } = window.location;
const pathWithOrigin = useMemo(() => {
if (isElectron() || isCapacitorNative()) {
const host = isElectron() ? ELECTRON_PROTOCOL_HOST : ANDROID_PROTOCOL_HOST;
const basename = trimSlash(hashRouter?.basename ?? '');
const routeParts = [basename, trimLeadingSlash(path)].filter(Boolean);
const route = `/${routeParts.join('/')}`.replace(/\/{2,}/g, '/');
return `paarrot://${host}${route}`;
}
let url: string = trimSlash(origin);
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
url = trimTrailingSlash(url);
if (hashRouter?.enabled) {
url += `/#/${trimSlash(hashRouter.basename ?? '')}`;
url = trimTrailingSlash(url);
}
url += `/${trimLeadingSlash(path)}`;
return url;
}, [path, hashRouter, origin]);
return pathWithOrigin;
};

View File

@@ -121,7 +121,8 @@ function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined {
const rawBody = typeof content.body === 'string' ? content.body : undefined;
switch (content.msgtype) {
case 'm.image':
return rawBody ? `📷 ${rawBody}` : '📷 Photo';
// Matrix body is usually a filename — skip it in the shade.
return '📷 Photo';
case 'm.video':
return rawBody ? `🎥 ${rawBody}` : '🎥 Video';
case 'm.audio':
@@ -129,7 +130,7 @@ function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined {
case 'm.file':
return rawBody ? `📎 ${rawBody}` : '📎 File';
case 'm.sticker':
return rawBody ? `🖼️ ${rawBody}` : '🖼️ Sticker';
return '🖼️ Sticker';
default:
return rawBody;
}

View File

@@ -0,0 +1,127 @@
import { App } from '@capacitor/app';
import { Browser } from '@capacitor/browser';
import { isCapacitorNative } from './tauri';
const PROTOCOL_SCHEME = 'paarrot';
/**
* Converts a paarrot:// deep link into an in-app hash route.
* Supports:
* - Hash routes: paarrot://anything/#/login/server/
* - Path routes (SSO-safe): paarrot://android/login/server/?loginToken=...
* Preserves SSO loginToken from the URL query (Synapse inserts it before `#`).
*/
export function deepLinkToHashRoute(deepLink: string): string | null {
if (!deepLink) {
return null;
}
if (!deepLink.toLowerCase().startsWith(`${PROTOCOL_SCHEME}://`)) {
return null;
}
let hashRoute: string | null = null;
let loginToken: string | null = null;
try {
const parsed = new URL(deepLink);
loginToken = parsed.searchParams.get('loginToken');
if (parsed.hash && parsed.hash.length > 1) {
const hashValue = parsed.hash.slice(1);
hashRoute = hashValue.startsWith('/') ? hashValue : `/${hashValue}`;
} else if (parsed.pathname && parsed.pathname !== '/') {
// paarrot://android/login/... → /login/...
hashRoute = parsed.pathname;
if (parsed.search && parsed.search.length > 1) {
const params = new URLSearchParams(parsed.search);
params.delete('loginToken');
const rest = params.toString();
if (rest) {
hashRoute += `?${rest}`;
}
}
}
} catch {
const hashIndex = deepLink.indexOf('#');
if (hashIndex !== -1) {
const hashValue = deepLink.slice(hashIndex + 1);
if (hashValue) {
hashRoute = hashValue.startsWith('/') ? hashValue : `/${hashValue}`;
}
}
const match = deepLink.match(/[?&]loginToken=([^&#]+)/i);
if (match) {
try {
loginToken = decodeURIComponent(match[1]);
} catch {
loginToken = match[1];
}
}
}
if (loginToken) {
if (!hashRoute) {
hashRoute = '/';
}
const queryIndex = hashRoute.indexOf('?');
const pathPart = queryIndex === -1 ? hashRoute : hashRoute.slice(0, queryIndex);
const params = new URLSearchParams(queryIndex === -1 ? '' : hashRoute.slice(queryIndex + 1));
if (!params.has('loginToken')) {
params.set('loginToken', loginToken);
}
const query = params.toString();
hashRoute = query ? `${pathPart}?${query}` : pathPart;
}
return hashRoute;
}
async function applyDeepLink(deepLink: string): Promise<void> {
const hashRoute = deepLinkToHashRoute(deepLink);
if (!hashRoute) {
return;
}
try {
await Browser.close();
} catch {
// Browser may already be closed or never opened.
}
const nextHash = hashRoute.startsWith('/') ? `#${hashRoute}` : `#/${hashRoute}`;
if (window.location.hash === nextHash) {
// Force TokenLogin to re-run if we land on the same route again.
window.location.hash = '#/';
window.setTimeout(() => {
window.location.hash = nextHash;
}, 0);
return;
}
window.location.hash = nextHash;
}
/**
* Listen for paarrot:// SSO returns (and cold-start launch URLs) on Capacitor Android.
*/
export async function initAndroidDeepLinks(): Promise<void> {
if (!isCapacitorNative()) {
return;
}
try {
const launch = await App.getLaunchUrl();
if (launch?.url) {
await applyDeepLink(launch.url);
}
} catch (error) {
console.warn('[androidDeepLink] Failed to read launch URL:', error);
}
await App.addListener('appUrlOpen', ({ url }) => {
if (!url) return;
void applyDeepLink(url);
});
}

View File

@@ -8,6 +8,9 @@ type UnifiedPushStatus = {
registered: boolean;
distributor: string;
distributors: string[] | string;
lastFailure?: string;
/** OEM App Boot / AUTO_START is blocking distributor broadcasts (e.g. TCL). */
autoStartBlocked?: boolean;
};
type ClearRoomNotificationsResult = {
@@ -52,6 +55,8 @@ interface MatrixBackgroundSyncPlugin {
setAppForeground(options: { foreground: boolean }): Promise<void>;
/** Returns fetch state and current UnifiedPush registration details. */
getStatus(): Promise<UnifiedPushStatus>;
/** Native Matrix gateway discovery for a UnifiedPush endpoint (avoids WebView CORS). */
resolveUnifiedPushGateway(options: { endpoint: string }): Promise<{ gatewayUrl: string }>;
/** Cancel native tray notifications posted for a Matrix room. */
clearRoomNotifications(options: { roomId: string }): Promise<ClearRoomNotificationsResult>;
/**
@@ -170,6 +175,15 @@ const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[]
};
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
// Prefer native HTTP — ntfy/Matrix gateway responses omit CORS headers, so
// WebView fetch fails with TypeError: Failed to fetch and wrongly falls back.
try {
const result = await MatrixBackgroundSync.resolveUnifiedPushGateway({ endpoint });
if (result?.gatewayUrl) return result.gatewayUrl;
} catch (err) {
console.warn('[BackgroundSync] Native UnifiedPush gateway discovery failed:', err);
}
try {
const discoveryUrl = new URL(endpoint);
discoveryUrl.pathname = '/_matrix/push/v1/notify';
@@ -526,6 +540,7 @@ export const setAppForegroundState = async (foreground: boolean): Promise<void>
* 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.
* Waits briefly for the distributor to return an endpoint so the settings UI can refresh accurately.
*/
export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
if (!isBackgroundSyncSupported()) {
@@ -536,7 +551,23 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
try {
const result = await MatrixBackgroundSync.requestDistributorSetup();
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
return result ?? { success: false };
if (!result?.success) return { success: false };
// Endpoint arrives asynchronously from the distributor after register().
for (let i = 0; i < 40; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 250));
const status = await getBackgroundSyncStatus();
if (status?.registered && status.endpoint) {
return { success: true };
}
if (status?.lastFailure) {
console.warn('[BackgroundSync] Registration failed while waiting:', status.lastFailure);
return { success: false };
}
}
console.warn('[BackgroundSync] Distributor selected but no endpoint received in time');
return { success: false };
} catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false };

View File

@@ -1,7 +1,5 @@
import FileSaver from 'file-saver';
import { Capacitor, registerPlugin } from '@capacitor/core';
import { downloadMedia } from './matrix';
import { getCurrentAccessToken } from './auth';
import { registerMediaSaver } from './saveMedia';
interface AndroidShareHandlerSavePlugin {
saveFile(options: {
@@ -9,19 +7,10 @@ interface AndroidShareHandlerSavePlugin {
mimeType?: string;
base64: string;
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
shareFile(options: {
filename: string;
mimeType?: string;
base64: string;
}): Promise<{ shared: boolean }>;
}
const AndroidShareHandler = registerPlugin<AndroidShareHandlerSavePlugin>('AndroidShareHandler');
const isAndroidNative = (): boolean =>
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
/** Convert a Blob to raw base64 (no data: prefix). */
const blobToBase64 = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -62,42 +51,17 @@ const guessMimeFromName = (filename: string): string | undefined => {
};
/**
* Persist a Blob to disk. On Capacitor Android, uses MediaStore via the native
* ShareHandler plugin (FileSaver / `<a download>` does not work in WebView).
* Elsewhere falls back to FileSaver.
* Wire Capacitor Android MediaStore saves into the core saveMedia helpers.
* Call once at app startup from the mobile overlay entry.
*/
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
if (isAndroidNative()) {
export function registerAndroidMediaSaver(): void {
if (!(Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android')) {
return;
}
registerMediaSaver(async (blob, filename) => {
const base64 = await blobToBase64(blob);
const mimeType = blob.type || guessMimeFromName(filename) || 'application/octet-stream';
await AndroidShareHandler.saveFile({ filename, mimeType, base64 });
return;
}
FileSaver.saveAs(blob, filename);
};
/**
* Fetch media (auth download or blob:/http URL) and save it via [saveMediaBlob].
*/
export const downloadAndSaveMedia = async (
src: string,
filename: string,
accessToken?: string | null
): Promise<void> => {
let blob: Blob;
try {
if (src.startsWith('blob:') || src.startsWith('data:')) {
const res = await fetch(src);
if (!res.ok) throw new Error(`Failed to fetch ${src}`);
blob = await res.blob();
} else {
blob = await downloadMedia(src, accessToken ?? getCurrentAccessToken());
}
} catch (error) {
console.warn('[saveMedia] downloadMedia failed, trying fetch fallback:', error);
const res = await fetch(src);
if (!res.ok) throw error;
blob = await res.blob();
}
await saveMediaBlob(blob, filename);
};
});
}

View File

@@ -4,7 +4,7 @@
*/
export const openExternalUrl = async (url: string): Promise<void> => {
console.log('[openExternalUrl] called with:', url);
// Use Electron's shell.openExternal if in Electron
if (isElectron()) {
try {
@@ -18,7 +18,20 @@ export const openExternalUrl = async (url: string): Promise<void> => {
console.warn('[openExternalUrl] Electron shell.openExternal failed:', err);
}
}
// Capacitor: SSO (and other handoffs) need Chrome Custom Tabs / in-app Browser
// so paarrot:// redirects return into the app instead of a blank external tab.
if (isCapacitorNative()) {
try {
const { Browser } = await import('@capacitor/browser');
await Browser.open({ url, presentationStyle: 'popover' });
console.log('[openExternalUrl] Capacitor Browser.open succeeded');
return;
} catch (err) {
console.warn('[openExternalUrl] Capacitor Browser.open failed:', err);
}
}
// Use Tauri for actual Tauri builds
if (isTauri() && !isElectron()) {
try {
@@ -30,7 +43,7 @@ export const openExternalUrl = async (url: string): Promise<void> => {
return;
} catch (pluginErr) {
console.warn('[openExternalUrl] Tauri opener plugin failed:', pluginErr);
// Fallback: try the custom command (useful if plugin fails due to ACL)
try {
console.log('[openExternalUrl] trying Tauri invoke command fallback...');
@@ -43,7 +56,7 @@ export const openExternalUrl = async (url: string): Promise<void> => {
}
}
}
console.log('[openExternalUrl] falling back to window.open');
window.open(url, '_blank');
};

View File

@@ -16,6 +16,8 @@ import { trimTrailingSlash } from './app/utils/common';
import App from './app/pages/App';
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
import { initAndroidDeepLinks } from './app/utils/androidDeepLink';
import { registerAndroidMediaSaver } from './app/utils/registerAndroidMediaSaver';
// import i18n (needs to be bundled ;))
import './app/i18n';
@@ -28,6 +30,12 @@ applySafeAreaInsets();
// Enable View Transitions API for smooth navigation
enableViewTransitionsForNavigation();
// Capacitor: paarrot:// SSO return closes Custom Tabs and lands on loginToken
void initAndroidDeepLinks();
// Capacitor Android: MediaStore saves for images / videos / files
registerAndroidMediaSaver();
// Register Service Worker
if ('serviceWorker' in navigator) {
const swUrl =

24
package-lock.json generated
View File

@@ -1,15 +1,17 @@
{
"name": "paarrot",
"version": "4.11.121",
"version": "4.11.150",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paarrot",
"version": "4.11.121",
"version": "4.11.150",
"license": "AGPL-3.0-only",
"dependencies": {
"@capacitor/android": "8.3.0",
"@capacitor/app": "8.0.1",
"@capacitor/browser": "8.0.1",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2",
@@ -33,6 +35,24 @@
"@capacitor/core": "^8.3.0"
}
},
"node_modules/@capacitor/app": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.0.1.tgz",
"integrity": "sha512-yeG3yyA0ETKqvgqexwHMBlmVOF13A1hRXzv/km0Ptv5TrNIZvZJK4MTI3uiqvnbHrzoJGP5DwWAjEXEfi90v3Q==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/browser": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.1.tgz",
"integrity": "sha512-cCEYK9DGJvamfCKOh95QP+VafApblySDR/eodOcl1Qt+d/Y+NNPzc8NYm/dztQlRf3aMLJ7ne1HrRCfu2UOMnw==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/cli": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "paarrot",
"version": "4.11.139",
"version": "4.11.153",
"description": "Paarrot - A Matrix client based on Cinny",
"engines": {
"node": ">=18.0.0"
@@ -23,6 +23,8 @@
"license": "AGPL-3.0-only",
"dependencies": {
"@capacitor/android": "8.3.0",
"@capacitor/app": "8.0.1",
"@capacitor/browser": "8.0.1",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2",