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.
This commit is contained in:
2026-07-24 15:26:31 +10:00
parent a4d56e095e
commit 8cf31b929d
10 changed files with 1210 additions and 25 deletions

View File

@@ -21,6 +21,8 @@ import android.graphics.PorterDuffXfermode
import android.graphics.Rect
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -118,13 +120,20 @@ class MatrixSyncService : Service() {
// "foreground" flag is stale; JS path is suppressed while backgrounded.
val fromPush = triggerReason.startsWith("unifiedpush")
val suppressBecauseUi = appInForeground && !fromPush
if (!isFirstSync && !suppressBecauseUi) {
processRoomEvents(json, userId)
if (!isFirstSync) {
// 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 {
Log.d(
TAG,
"Skipping notifications (firstSync=$isFirstSync, foreground=$appInForeground, reason=$triggerReason)",
)
Log.d(TAG, "Skipping notifications (firstSync=true, reason=$triggerReason)")
}
}
}
@@ -189,6 +198,24 @@ class MatrixSyncService : Service() {
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) {
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
@@ -766,11 +793,48 @@ class MatrixSyncService : Service() {
fun sanitizeNotificationText(text: String): String =
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
}
/**
* Posts a room notification (+ space/DM group summary) for both background sync
* and JS-driven Capacitor notifications.
*
* Avatar goes in the circular Person / largeIcon slot on the left.
* Avatar goes in the circular largeIcon / conversation-shortcut slot (visible when
* collapsed). The small status-bar icon stays the Paarrot mark (Android requirement).
* Optional image attachments use BigPictureStyle while keeping that circular avatar.
*/
fun postMessageNotification(
@@ -821,7 +885,8 @@ class MatrixSyncService : Service() {
val avatar = largeIcon?.let { toCircularBitmap(it) }
val personBuilder = Person.Builder()
.setName(resolvedSender)
.setKey(resolvedSender)
.setKey("$roomId:$resolvedSender")
.setImportant(true)
if (avatar != null) {
personBuilder.setIcon(IconCompat.createWithBitmap(avatar))
}
@@ -839,6 +904,14 @@ class MatrixSyncService : Service() {
"$resolvedSender: $resolvedMessage"
}
val shortcut = publishConversationShortcut(
context,
roomId,
collapsedTitle,
senderPerson,
launchIntent,
)
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(collapsedTitle)
@@ -850,7 +923,8 @@ class MatrixSyncService : Service() {
.setGroup(groupId)
.setOnlyAlertOnce(true)
.setSubText(groupName)
.setShortcutId(roomId)
.setShortcutId(shortcut.id)
.setShortcutInfo(shortcut)
// Prevent system "Open link in Chrome/Firefox" contextual actions on URL bodies.
.setAllowSystemGeneratedContextualActions(false)
@@ -860,7 +934,7 @@ class MatrixSyncService : Service() {
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 (inlineImage != null) {
@@ -873,19 +947,14 @@ class MatrixSyncService : Service() {
.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)
// BigTextStyle keeps setLargeIcon visible when collapsed on most OEMs.
// MessagingStyle alone often hides the avatar until the user expands.
builder.setStyle(
NotificationCompat.BigTextStyle()
.bigText(collapsedText)
.setBigContentTitle(collapsedTitle)
.setSummaryText(groupName)
)
}
nm.notify(roomNotifId, builder.build())

View File

@@ -1,10 +1,14 @@
package com.paarrot.app
import android.app.Activity
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
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.JSObject
import com.getcapacitor.Plugin
@@ -213,4 +217,168 @@ class ShareHandlerPlugin : Plugin() {
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
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"
}
}