Fix Android media downloads and dismiss notifs when read elsewhere.
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:
@@ -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())
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user