Compare commits

..

10 Commits

Author SHA1 Message Date
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
GitHub Actions
5f7af04a57 chore: bump version to 4.11.139 [skip ci] 2026-07-24 05:41:58 +00:00
62b9c48fa1 Accumulate multi-message Android notifications per room.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m43s
Use MessagingStyle with persisted history so rapid messages append instead of each update wiping the previous body.
2026-07-24 15:41:50 +10:00
GitHub Actions
19a46c4d95 chore: bump version to 4.11.138 [skip ci] 2026-07-24 05:26:41 +00:00
8cf31b929d Fix Android media downloads and dismiss notifs when read elsewhere.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 5m5s
FileSaver fails in Capacitor WebView, so save via MediaStore; clear tray notifications when sync reports rooms as read on another device, and improve collapsed notification avatars with conversation shortcuts.
2026-07-24 15:26:35 +10:00
17 changed files with 1554 additions and 58 deletions

View File

@@ -87,4 +87,22 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <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> </manifest>

View File

@@ -21,6 +21,8 @@ import android.graphics.PorterDuffXfermode
import android.graphics.Rect import android.graphics.Rect
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -29,6 +31,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
@@ -118,13 +121,20 @@ class MatrixSyncService : Service() {
// "foreground" flag is stale; JS path is suppressed while backgrounded. // "foreground" flag is stale; JS path is suppressed while backgrounded.
val fromPush = triggerReason.startsWith("unifiedpush") val fromPush = triggerReason.startsWith("unifiedpush")
val suppressBecauseUi = appInForeground && !fromPush val suppressBecauseUi = appInForeground && !fromPush
if (!isFirstSync && !suppressBecauseUi) { if (!isFirstSync) {
processRoomEvents(json, userId) // Always dismiss trays when another device (or this one) marked rooms read,
// even while the UI is foregrounded and posting is suppressed.
dismissClearedRoomNotifications(json)
if (!suppressBecauseUi) {
processRoomEvents(json, userId)
} else {
Log.d(
TAG,
"Skipping notification posts (foreground=$appInForeground, reason=$triggerReason)",
)
}
} else { } else {
Log.d( Log.d(TAG, "Skipping notifications (firstSync=true, reason=$triggerReason)")
TAG,
"Skipping notifications (firstSync=$isFirstSync, foreground=$appInForeground, reason=$triggerReason)",
)
} }
} }
} }
@@ -189,6 +199,24 @@ class MatrixSyncService : Service() {
val pushRules: JSONObject?, val pushRules: JSONObject?,
) )
/**
* Cancel tray notifications for rooms the homeserver now reports as fully read.
* Covers “marked as read on another device” while this phone is backgrounded.
* Only acts when [unread_notifications] is present in this sync batch (count changed).
*/
private fun dismissClearedRoomNotifications(sync: JSONObject) {
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
for (roomId in joinedRooms.keys().asSequence()) {
val roomData = joinedRooms.optJSONObject(roomId) ?: continue
if (!roomData.has("unread_notifications")) continue
val unread = roomData.optJSONObject("unread_notifications")
val notificationCount = unread?.optInt("notification_count", 0) ?: 0
if (notificationCount <= 0) {
clearRoomNotifications(applicationContext, roomId)
}
}
}
private fun processRoomEvents(sync: JSONObject, myUserId: String) { private fun processRoomEvents(sync: JSONObject, myUserId: String) {
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE) val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
@@ -679,6 +707,8 @@ class MatrixSyncService : Service() {
private const val GROUP_DIRECTS = "paarrot_directs" private const val GROUP_DIRECTS = "paarrot_directs"
private const val GROUP_HOME = "paarrot_home" private const val GROUP_HOME = "paarrot_home"
private const val MIN_WAKE_INTERVAL_MS = 7_500L private const val MIN_WAKE_INTERVAL_MS = 7_500L
private const val MAX_MESSAGING_HISTORY = 8
private const val KEY_MSG_HISTORY_PREFIX = "notif_hist_"
const val MODE_ONE_SHOT = "one_shot" const val MODE_ONE_SHOT = "one_shot"
/** Same hashing scheme as JS `notificationIdForRoom` so clear-on-read hits both paths. */ /** Same hashing scheme as JS `notificationIdForRoom` so clear-on-read hits both paths. */
@@ -766,12 +796,121 @@ class MatrixSyncService : Service() {
fun sanitizeNotificationText(text: String): String = fun sanitizeNotificationText(text: String): String =
text.replace(Regex("""https?://\S+""", RegexOption.IGNORE_CASE), "🔗 link") text.replace(Regex("""https?://\S+""", RegexOption.IGNORE_CASE), "🔗 link")
/**
* Publish a long-lived conversation shortcut so Android 11+ shows the sender
* avatar in the collapsed shade (MessagingStyle alone only shows it expanded).
*/
private fun publishConversationShortcut(
context: Context,
roomId: String,
label: String,
person: Person,
launchIntent: Intent,
): ShortcutInfoCompat {
val shortcutIntent = Intent(launchIntent).apply {
// Shortcuts require an explicit action.
if (action.isNullOrBlank()) {
action = NotificationNavStore.ACTION_OPEN_NOTIFICATION
}
}
val icon = person.icon
?: IconCompat.createWithResource(context, R.drawable.ic_stat_paarrot)
val shortcut = ShortcutInfoCompat.Builder(context, "room:$roomId")
.setShortLabel(label.take(25).ifBlank { "Chat" })
.setLongLabel(label.ifBlank { "Chat" })
.setIcon(icon)
.setIntent(shortcutIntent)
.setPerson(person)
.setLongLived(true)
.setCategories(setOf("android.shortcut.conversation"))
.build()
try {
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
} catch (e: Exception) {
Log.w(TAG, "Failed to publish conversation shortcut: ${e.message}")
}
return shortcut
}
private data class PendingNotifMessage(
val sender: String,
val text: String,
val timestamp: Long,
)
private fun historyPrefsKey(roomId: String) = KEY_MSG_HISTORY_PREFIX + roomId
private fun loadMessageHistory(context: Context, roomId: String): List<PendingNotifMessage> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(historyPrefsKey(roomId), null)
?: return emptyList()
return try {
val arr = JSONArray(raw)
buildList {
for (i in 0 until arr.length()) {
val obj = arr.optJSONObject(i) ?: continue
val sender = obj.optString("sender")
val text = obj.optString("text")
val ts = obj.optLong("ts", 0L)
if (sender.isBlank() || text.isBlank()) continue
add(PendingNotifMessage(sender, text, ts))
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load notification history for $roomId: ${e.message}")
emptyList()
}
}
private fun saveMessageHistory(
context: Context,
roomId: String,
messages: List<PendingNotifMessage>,
) {
val arr = JSONArray()
for (msg in messages) {
arr.put(
JSONObject()
.put("sender", msg.sender)
.put("text", msg.text)
.put("ts", msg.timestamp)
)
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(historyPrefsKey(roomId), arr.toString())
.apply()
}
private fun clearMessageHistory(context: Context, roomId: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.remove(historyPrefsKey(roomId))
.apply()
}
private fun appendMessageHistory(
context: Context,
roomId: String,
sender: String,
text: String,
): List<PendingNotifMessage> {
val next = (loadMessageHistory(context, roomId) + PendingNotifMessage(
sender = sender,
text = text,
timestamp = System.currentTimeMillis(),
)).takeLast(MAX_MESSAGING_HISTORY)
saveMessageHistory(context, roomId, next)
return next
}
/** /**
* Posts a room notification (+ space/DM group summary) for both background sync * Posts a room notification (+ space/DM group summary) for both background sync
* and JS-driven Capacitor notifications. * and JS-driven Capacitor notifications.
* *
* Avatar goes in the circular Person / largeIcon slot on the left. * Uses MessagingStyle with persisted per-room history so rapid messages accumulate
* Optional image attachments use BigPictureStyle while keeping that circular avatar. * instead of each update wiping the previous body (stable per-room notification id).
* Avatar goes in largeIcon + conversation shortcut (visible when collapsed).
*/ */
fun postMessageNotification( fun postMessageNotification(
context: Context, context: Context,
@@ -821,12 +960,15 @@ class MatrixSyncService : Service() {
val avatar = largeIcon?.let { toCircularBitmap(it) } val avatar = largeIcon?.let { toCircularBitmap(it) }
val personBuilder = Person.Builder() val personBuilder = Person.Builder()
.setName(resolvedSender) .setName(resolvedSender)
.setKey(resolvedSender) .setKey("$roomId:$resolvedSender")
.setImportant(true)
if (avatar != null) { if (avatar != null) {
personBuilder.setIcon(IconCompat.createWithBitmap(avatar)) personBuilder.setIcon(IconCompat.createWithBitmap(avatar))
} }
val senderPerson = personBuilder.build() val senderPerson = personBuilder.build()
val history = appendMessageHistory(context, roomId, resolvedSender, resolvedMessage)
val channelId = channelIdForKindStatic(kind) val channelId = channelIdForKindStatic(kind)
val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) { val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) {
resolvedSender resolvedSender
@@ -839,6 +981,42 @@ class MatrixSyncService : Service() {
"$resolvedSender: $resolvedMessage" "$resolvedSender: $resolvedMessage"
} }
val shortcut = publishConversationShortcut(
context,
roomId,
collapsedTitle,
senderPerson,
launchIntent,
)
val selfPerson = Person.Builder()
.setName("Me")
.setKey("self")
.build()
val messagingStyle = NotificationCompat.MessagingStyle(selfPerson)
.setGroupConversation(!isDm)
if (!isDm && !resolvedConversation.isNullOrBlank()) {
messagingStyle.conversationTitle = resolvedConversation
}
for (msg in history) {
val person = if (msg.sender == resolvedSender) {
senderPerson
} else {
Person.Builder()
.setName(msg.sender)
.setKey("$roomId:${msg.sender}")
.setImportant(true)
.build()
}
messagingStyle.addMessage(
NotificationCompat.MessagingStyle.Message(
msg.text,
msg.timestamp,
person,
)
)
}
val builder = NotificationCompat.Builder(context, channelId) val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_stat_paarrot) .setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(collapsedTitle) .setContentTitle(collapsedTitle)
@@ -849,10 +1027,13 @@ class MatrixSyncService : Service() {
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(groupId) .setGroup(groupId)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setNumber(history.size)
.setSubText(groupName) .setSubText(groupName)
.setShortcutId(roomId) .setShortcutId(shortcut.id)
.setShortcutInfo(shortcut)
// Prevent system "Open link in Chrome/Firefox" contextual actions on URL bodies. // Prevent system "Open link in Chrome/Firefox" contextual actions on URL bodies.
.setAllowSystemGeneratedContextualActions(false) .setAllowSystemGeneratedContextualActions(false)
.setStyle(messagingStyle)
builder.extras.putString(EXTRA_ROOM_ID, roomId) builder.extras.putString(EXTRA_ROOM_ID, roomId)
builder.extras.putString(EXTRA_GROUP_ID, groupId) builder.extras.putString(EXTRA_GROUP_ID, groupId)
@@ -860,11 +1041,11 @@ class MatrixSyncService : Service() {
builder.extras.putString(NotificationNavStore.EXTRA_NAV_PATH, path) builder.extras.putString(NotificationNavStore.EXTRA_NAV_PATH, path)
} }
// Circular avatar on the left (collapsed + MessagingStyle Person). // Collapsed shade avatar (large icon). Small icon must stay the monochrome app mark.
if (avatar != null) builder.setLargeIcon(avatar) if (avatar != null) builder.setLargeIcon(avatar)
if (inlineImage != null) { // Image attachments: keep MessagingStyle history; BigPicture would wipe prior lines.
// Expanded image attachment — keep avatar in the circular large-icon slot. if (inlineImage != null && history.size == 1) {
builder.setStyle( builder.setStyle(
NotificationCompat.BigPictureStyle() NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage) .bigPicture(inlineImage)
@@ -872,20 +1053,6 @@ class MatrixSyncService : Service() {
.setBigContentTitle(collapsedTitle) .setBigContentTitle(collapsedTitle)
.setSummaryText(collapsedText) .setSummaryText(collapsedText)
) )
} else {
val messaging = NotificationCompat.MessagingStyle(senderPerson)
.addMessage(
NotificationCompat.MessagingStyle.Message(
resolvedMessage,
System.currentTimeMillis(),
senderPerson,
)
)
if (!resolvedConversation.isNullOrBlank() && !isDm) {
messaging.conversationTitle = resolvedConversation
messaging.isGroupConversation = true
}
builder.setStyle(messaging)
} }
nm.notify(roomNotifId, builder.build()) nm.notify(roomNotifId, builder.build())
@@ -914,6 +1081,7 @@ class MatrixSyncService : Service() {
/** Cancel the tray notification posted for [roomId], if any. */ /** Cancel the tray notification posted for [roomId], if any. */
fun clearRoomNotifications(context: Context, roomId: String) { fun clearRoomNotifications(context: Context, roomId: String) {
if (roomId.isBlank()) return if (roomId.isBlank()) return
clearMessageHistory(context, roomId)
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val roomNotifId = notificationIdForRoom(roomId) val roomNotifId = notificationIdForRoom(roomId)

View File

@@ -1,10 +1,14 @@
package com.paarrot.app package com.paarrot.app
import android.app.Activity import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore
import android.util.Base64
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import com.getcapacitor.JSArray import com.getcapacitor.JSArray
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
@@ -213,4 +217,168 @@ class ShareHandlerPlugin : Plugin() {
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt) ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
call.resolve() call.resolve()
} }
/**
* Save a base64-encoded file to the device gallery / Downloads.
* Capacitor WebView cannot use FileSaver / `<a download>`, so downloads go through MediaStore.
* Images → Pictures/Paarrot, videos → Movies/Paarrot, other → Download/Paarrot (API 29+).
* On older APIs or MediaStore failure, falls back to the system share sheet.
*/
@PluginMethod
fun saveFile(call: PluginCall) {
val filename = call.getString("filename") ?: run {
call.reject("filename required")
return
}
val mimeType = call.getString("mimeType")
?: guessMime(filename)
?: "application/octet-stream"
val base64 = call.getString("base64") ?: run {
call.reject("base64 required")
return
}
try {
val cleaned = base64.substringAfter("base64,", base64).trim()
val bytes = Base64.decode(cleaned, Base64.DEFAULT)
if (bytes.isEmpty()) {
call.reject("empty file data")
return
}
val uri = saveBytes(bytes, filename, mimeType)
if (uri != null) {
android.widget.Toast.makeText(
context,
"Saved $filename",
android.widget.Toast.LENGTH_SHORT
).show()
call.resolve(
JSObject()
.put("saved", true)
.put("uri", uri.toString())
)
} else {
shareBytes(bytes, filename, mimeType)
call.resolve(JSObject().put("saved", false).put("shared", true))
}
} catch (e: Exception) {
android.util.Log.e(TAG, "saveFile failed", e)
call.reject("Failed to save file: ${e.message}", e)
}
}
/**
* Open the system share sheet for a base64 file (Save to Files, Drive, etc.).
*/
@PluginMethod
fun shareFile(call: PluginCall) {
val filename = call.getString("filename") ?: run {
call.reject("filename required")
return
}
val mimeType = call.getString("mimeType")
?: guessMime(filename)
?: "application/octet-stream"
val base64 = call.getString("base64") ?: run {
call.reject("base64 required")
return
}
try {
val cleaned = base64.substringAfter("base64,", base64).trim()
val bytes = Base64.decode(cleaned, Base64.DEFAULT)
shareBytes(bytes, filename, mimeType)
call.resolve(JSObject().put("shared", true))
} catch (e: Exception) {
android.util.Log.e(TAG, "shareFile failed", e)
call.reject("Failed to share file: ${e.message}", e)
}
}
private fun saveBytes(bytes: ByteArray, filename: String, mimeType: String): Uri? {
return when {
mimeType.startsWith("image/") ->
insertMedia(bytes, filename, mimeType, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "Pictures/Paarrot")
mimeType.startsWith("video/") ->
insertMedia(bytes, filename, mimeType, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "Movies/Paarrot")
mimeType.startsWith("audio/") ->
insertMedia(bytes, filename, mimeType, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, "Music/Paarrot")
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
insertMedia(bytes, filename, mimeType, MediaStore.Downloads.EXTERNAL_CONTENT_URI, "Download/Paarrot")
else -> null // share-sheet fallback (avoids WRITE_EXTERNAL_STORAGE on API < 29)
}
}
private fun insertMedia(
bytes: ByteArray,
filename: String,
mimeType: String,
collection: Uri,
relativePath: String
): Uri? {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
}
val resolver = context.contentResolver
val uri = resolver.insert(collection, values) ?: return null
try {
resolver.openOutputStream(uri)?.use { it.write(bytes) }
?: run {
resolver.delete(uri, null, null)
return null
}
} catch (e: Exception) {
resolver.delete(uri, null, null)
throw e
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.clear()
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(uri, values, null, null)
}
return uri
}
private fun shareBytes(bytes: ByteArray, filename: String, mimeType: String) {
val cacheDir = File(context.cacheDir, "shares").apply { mkdirs() }
val safeName = filename.replace(Regex("[^a-zA-Z0-9._\\-]"), "_").take(128).ifEmpty { "file" }
val file = File(cacheDir, safeName)
FileOutputStream(file).use { it.write(bytes) }
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, filename)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, "Save or share").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(chooser)
}
private fun guessMime(filename: String): String? {
val ext = filename.substringAfterLast('.', "").lowercase()
if (ext.isEmpty()) return null
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
}
companion object {
private const val TAG = "ShareHandlerPlugin"
}
} }

View File

@@ -17,6 +17,8 @@ import com.getcapacitor.annotation.CapacitorPlugin
* - `stop()` — clear persisted credentials and unregister UnifiedPush * - `stop()` — clear persisted credentials and unregister UnifiedPush
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible * - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
* - `getStatus()` — returns current fetch and UnifiedPush state * - `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 * - `clearRoomNotifications({ roomId })` — dismiss native tray notifs for a room
* - `setNotificationGroups({ rooms })` — persist space/DM grouping for tray nesting * - `setNotificationGroups({ rooms })` — persist space/DM grouping for tray nesting
* - `showNotification({ title, body, roomId, groupId, groupName, kind, largeIconBase64 })` * - `showNotification({ title, body, roomId, groupId, groupName, kind, largeIconBase64 })`
@@ -131,6 +133,35 @@ class SyncServicePlugin : Plugin() {
call.resolve(result) 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. * Cancels native tray notifications for a Matrix room after it has been marked as read.
*/ */

View File

@@ -1,12 +1,18 @@
package com.paarrot.app package com.paarrot.app
import android.app.Activity import android.app.Activity
import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.util.Log import android.util.Log
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import org.json.JSONObject
import org.unifiedpush.android.connector.FailedReason import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.INSTANCE_DEFAULT import org.unifiedpush.android.connector.INSTANCE_DEFAULT
import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.UnifiedPush
import java.net.HttpURLConnection
import java.net.URL
/** Coordinates UnifiedPush registration state and bridges events back to JS. */ /** Coordinates UnifiedPush registration state and bridges events back to JS. */
object UnifiedPushManager { object UnifiedPushManager {
@@ -18,6 +24,7 @@ object UnifiedPushManager {
private const val EVENT_UNREGISTERED = "unifiedPushUnregistered" private const val EVENT_UNREGISTERED = "unifiedPushUnregistered"
private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed" private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed"
private const val DEFAULT_MESSAGE = "Paarrot notifications" private const val DEFAULT_MESSAGE = "Paarrot notifications"
const val DEFAULT_MATRIX_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
@Volatile @Volatile
private var plugin: SyncServicePlugin? = null private var plugin: SyncServicePlugin? = null
@@ -44,15 +51,111 @@ object UnifiedPushManager {
return return
} }
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success -> // Prefer an explicit in-app picker so the user always sees something;
if (success) { // tryUseDefaultDistributor is silent when only one distributor is installed.
requestRegistration(context) requestDistributorSetup(context, activity) { success ->
} else { if (!success) {
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT) dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
} }
} }
} }
/**
* Clears any saved distributor and shows an in-app distributor picker.
* UnifiedPush's OS deeplink picker is silent when only one distributor
* (e.g. ntfy) is installed, which looked like a broken Reset button.
*/
fun requestDistributorSetup(context: Context, activity: Activity, onDone: (Boolean) -> Unit) {
runCatching { UnifiedPush.removeDistributor(context) }
.onFailure { Log.w(TAG, "removeDistributor failed: ${it.message}") }
clearEndpoint(context)
val distributors = runCatching { UnifiedPush.getDistributors(context) }
.getOrDefault(emptyList())
if (distributors.isEmpty()) {
Log.w(TAG, "No UnifiedPush distributors installed")
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
}
runCatching { UnifiedPush.saveDistributor(context, chosen) }
.onFailure { Log.w(TAG, "saveDistributor failed: ${it.message}") }
requestRegistration(context)
finish(true)
}
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) }
.setOnCancelListener { finish(false) }
.show()
}
}
/** 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) { fun unregister(context: Context) {
runCatching { runCatching {
UnifiedPush.unregister(context, INSTANCE_DEFAULT) UnifiedPush.unregister(context, INSTANCE_DEFAULT)
@@ -70,10 +173,11 @@ object UnifiedPushManager {
status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT) status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT)
status.put("registered", !getEndpoint(context).isNullOrBlank()) status.put("registered", !getEndpoint(context).isNullOrBlank())
status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault("")) status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault(""))
status.put( val distributors = JSArray()
"distributors", runCatching { UnifiedPush.getDistributors(context) }
runCatching { UnifiedPush.getDistributors(context) }.getOrDefault(emptyList<String>()) .getOrDefault(emptyList())
) .forEach { distributors.put(it) }
status.put("distributors", distributors)
return status return status
} }

2
cinny

Submodule cinny updated: b52926f7d8...e460dbd34e

View File

@@ -0,0 +1,251 @@
/* 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

@@ -0,0 +1,107 @@
/* 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

@@ -0,0 +1,84 @@
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

@@ -0,0 +1,303 @@
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

@@ -0,0 +1,74 @@
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,30 @@ type PushStatus = {
registered: boolean; registered: boolean;
distributor: string; distributor: string;
endpoint: string; endpoint: string;
distributors: string[];
}; };
/** Android-only section showing UnifiedPush registration status and controls. */ /** Android-only section showing UnifiedPush registration status and controls. */
function AndroidPushNotifications() { function AndroidPushNotifications() {
const [status, setStatus] = useState<PushStatus | undefined>(undefined); const [status, setStatus] = useState<PushStatus | undefined>(undefined);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [lastError, setLastError] = useState<string | undefined>(undefined);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true); setLoading(true);
const s = await getBackgroundSyncStatus(); const s = await getBackgroundSyncStatus();
const distributors = Array.isArray(s?.distributors)
? s.distributors
: typeof s?.distributors === 'string' && s.distributors && s.distributors !== '[]'
? [s.distributors]
: [];
setStatus( setStatus(
s s
? { ? {
registered: s.registered, registered: s.registered,
distributor: s.distributor || '', distributor: s.distributor || '',
endpoint: s.endpoint || '', endpoint: s.endpoint || '',
distributors,
} }
: undefined : undefined
); );
@@ -198,8 +206,14 @@ function AndroidPushNotifications() {
const [resetState, reset] = useAsyncCallback( const [resetState, reset] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
await requestResetPushRegistration(); setLastError(undefined);
const result = await requestResetPushRegistration();
await refresh(); await refresh();
if (!result.success) {
setLastError(
'Could not register a push distributor. Install ntfy (with UnifiedPush enabled), then try Reset again.'
);
}
}, [refresh]) }, [refresh])
); );
@@ -214,6 +228,36 @@ function AndroidPushNotifications() {
resetState.status === AsyncStatus.Loading || resetState.status === AsyncStatus.Loading ||
pingState.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.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 ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Android Push (UnifiedPush)</Text> <Text size="L400">Android Push (UnifiedPush)</Text>
@@ -226,29 +270,20 @@ function AndroidPushNotifications() {
<SettingTile <SettingTile
title="Background Notifications" title="Background Notifications"
description={ description={
loading ? ( <>
'Loading status…' {statusDescription}
) : status === undefined ? ( {lastError ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200"> <Text as="span" style={{ color: color.Critical.Main, display: 'block' }} size="T200">
Failed to read push status. {lastError}
</Text>
) : status.registered ? (
<>
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
</Text> </Text>
</> ) : null}
) : ( </>
<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} after={loading ? <Spinner variant="Secondary" /> : undefined}
/> />
<SettingTile <SettingTile
title="Change Distributor" 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={ after={
<Button <Button
size="300" size="300"

View File

@@ -425,16 +425,30 @@ function MessageNotifications() {
const roomToUnread = useAtomValue(roomToUnreadAtom); const roomToUnread = useAtomValue(roomToUnreadAtom);
const previousUnreadRoomsRef = useRef<Set<string>>(new Set()); const previousUnreadRoomsRef = useRef<Set<string>>(new Set());
const previousUnreadTotalsRef = useRef<Map<string, number>>(new Map());
// Dismiss OS notifications when a room's unread is cleared (local read or receipt). // Dismiss OS notifications when a room's unread is cleared
// (local mark-as-read, or our receipt syncing from another device).
useEffect(() => { useEffect(() => {
const currentRooms = new Set(roomToUnread.keys()); const currentRooms = new Set(roomToUnread.keys());
const nextTotals = new Map<string, number>();
roomToUnread.forEach((unread, roomId) => {
nextTotals.set(roomId, unread.total);
const prevTotal = previousUnreadTotalsRef.current.get(roomId);
if (prevTotal !== undefined && prevTotal > 0 && unread.total === 0) {
void clearNotificationsForRoom(roomId);
}
});
for (const roomId of previousUnreadRoomsRef.current) { for (const roomId of previousUnreadRoomsRef.current) {
if (!currentRooms.has(roomId)) { if (!currentRooms.has(roomId)) {
void clearNotificationsForRoom(roomId); void clearNotificationsForRoom(roomId);
} }
} }
previousUnreadRoomsRef.current = currentRooms; previousUnreadRoomsRef.current = currentRooms;
previousUnreadTotalsRef.current = nextTotals;
}, [roomToUnread]); }, [roomToUnread]);
const notify = useCallback( const notify = useCallback(

View File

@@ -21,6 +21,18 @@ interface AndroidShareHandlerPlugin {
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>; getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
/** Clears the last pending share payload after it has been consumed. */ /** Clears the last pending share payload after it has been consumed. */
clearPendingShare(): Promise<void>; clearPendingShare(): Promise<void>;
/** Save a base64 file to gallery / Downloads (MediaStore), or share-sheet fallback. */
saveFile(options: {
filename: string;
mimeType?: string;
base64: string;
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
/** Open the system share sheet for a base64 file. */
shareFile(options: {
filename: string;
mimeType?: string;
base64: string;
}): Promise<{ shared: boolean }>;
addListener( addListener(
eventName: 'shareReceived', eventName: 'shareReceived',
listenerFunc: (payload: AndroidSharePayload) => void listenerFunc: (payload: AndroidSharePayload) => void

View File

@@ -52,6 +52,8 @@ interface MatrixBackgroundSyncPlugin {
setAppForeground(options: { foreground: boolean }): Promise<void>; setAppForeground(options: { foreground: boolean }): Promise<void>;
/** Returns fetch state and current UnifiedPush registration details. */ /** Returns fetch state and current UnifiedPush registration details. */
getStatus(): Promise<UnifiedPushStatus>; 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. */ /** Cancel native tray notifications posted for a Matrix room. */
clearRoomNotifications(options: { roomId: string }): Promise<ClearRoomNotificationsResult>; clearRoomNotifications(options: { roomId: string }): Promise<ClearRoomNotificationsResult>;
/** /**
@@ -170,6 +172,15 @@ const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[]
}; };
const resolveUnifiedPushGateway = async (endpoint: string): Promise<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 { try {
const discoveryUrl = new URL(endpoint); const discoveryUrl = new URL(endpoint);
discoveryUrl.pathname = '/_matrix/push/v1/notify'; discoveryUrl.pathname = '/_matrix/push/v1/notify';
@@ -526,6 +537,7 @@ export const setAppForegroundState = async (foreground: boolean): Promise<void>
* Re-opens the UnifiedPush distributor selection dialog. * Re-opens the UnifiedPush distributor selection dialog.
* Allows users to re-select or change their push notification distributor without terminal access. * Allows users to re-select or change their push notification distributor without terminal access.
* Useful when the current endpoint becomes unavailable. * 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 }> => { export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
if (!isBackgroundSyncSupported()) { if (!isBackgroundSyncSupported()) {
@@ -536,7 +548,19 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
try { try {
const result = await MatrixBackgroundSync.requestDistributorSetup(); const result = await MatrixBackgroundSync.requestDistributorSetup();
console.log('[BackgroundSync] requestDistributorSetup completed:', result); 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 < 20; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 250));
const status = await getBackgroundSyncStatus();
if (status?.registered && status.endpoint) {
return { success: true };
}
}
// Picker succeeded but endpoint not yet ack'd — still treat as success.
return { success: true };
} catch (err) { } catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err); console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false }; return { success: false };

View File

@@ -0,0 +1,103 @@
import FileSaver from 'file-saver';
import { Capacitor, registerPlugin } from '@capacitor/core';
import { downloadMedia } from './matrix';
import { getCurrentAccessToken } from './auth';
interface AndroidShareHandlerSavePlugin {
saveFile(options: {
filename: string;
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();
reader.onload = () => {
const result = reader.result;
if (typeof result !== 'string') {
reject(new Error('Failed to read blob as base64'));
return;
}
resolve(result.includes(',') ? result.split(',')[1] : result);
};
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
reader.readAsDataURL(blob);
});
const guessMimeFromName = (filename: string): string | undefined => {
const ext = filename.split('.').pop()?.toLowerCase();
if (!ext) return undefined;
const map: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
svg: 'image/svg+xml',
mp4: 'video/mp4',
webm: 'video/webm',
mkv: 'video/x-matroska',
mov: 'video/quicktime',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
wav: 'audio/wav',
pdf: 'application/pdf',
txt: 'text/plain',
};
return map[ext];
};
/**
* 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.
*/
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
if (isAndroidNative()) {
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

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