Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fdf369883 | ||
| c9883a1279 | |||
|
|
5f09b0dd71 | ||
| 0e436f076e | |||
| 2281b31894 | |||
|
|
0eda439897 | ||
| a1bd03b277 | |||
|
|
1c2904898f | ||
| 0fe41a55c1 | |||
| af2f7dbcbc |
@@ -1,5 +1,5 @@
|
||||
# Android App Signing Setup
|
||||
|
||||
|
||||
## Why This Matters
|
||||
Android requires apps to be signed with the same key for updates to work. Without proper signing, users cannot update your app - they must uninstall and reinstall, losing all data.
|
||||
|
||||
|
||||
@@ -35,8 +35,16 @@ android {
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
|
||||
@@ -5,10 +5,14 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.media.AudioAttributes
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.core.app.NotificationCompat
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -37,6 +41,11 @@ class MatrixSyncService : Service() {
|
||||
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
|
||||
private val displayNameCache = HashMap<String, String>(16)
|
||||
|
||||
/** In-memory cache of MXID → avatar bitmap (null = no avatar or fetch failed). */
|
||||
private val avatarCache = HashMap<String, Bitmap?>(16)
|
||||
|
||||
private data class UserProfile(val displayName: String, val avatar: Bitmap?)
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -165,24 +174,45 @@ class MatrixSyncService : Service() {
|
||||
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
||||
|
||||
val content = event.optJSONObject("content") ?: continue
|
||||
val body = content.optString("body").takeIf { it.isNotBlank() } ?: continue
|
||||
val sender = event.optString("sender")
|
||||
val senderName = resolveDisplayName(sender, homeserver, token)
|
||||
val msgtype = content.optString("msgtype")
|
||||
val rawBody = content.optString("body")
|
||||
val body = when (msgtype) {
|
||||
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo"
|
||||
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video"
|
||||
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
|
||||
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
|
||||
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker"
|
||||
else -> rawBody.takeIf { it.isNotBlank() } ?: continue
|
||||
}
|
||||
|
||||
showMessageNotification(nm, senderName, body)
|
||||
val sender = event.optString("sender")
|
||||
val profile = resolveProfile(sender, homeserver, token)
|
||||
|
||||
// For unencrypted image/sticker messages, try to download a preview bitmap.
|
||||
// Encrypted messages have a `file` object instead of a top-level `url`.
|
||||
val inlineImage: Bitmap? = if (
|
||||
(msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file")
|
||||
) {
|
||||
content.optString("url").takeIf { it.startsWith("mxc://") }
|
||||
?.let { mxcToDownloadUrl(it, homeserver) }
|
||||
?.let { downloadBitmap(it, token) }
|
||||
} else null
|
||||
|
||||
showMessageNotification(nm, profile.displayName, body, profile.avatar, inlineImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDisplayName(mxid: String, homeserver: String, token: String): String {
|
||||
displayNameCache[mxid]?.let { return it }
|
||||
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
|
||||
val cachedName = displayNameCache[mxid]
|
||||
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
|
||||
|
||||
val fallback = mxid.substringAfter("@").substringBefore(":")
|
||||
return try {
|
||||
val encodedId = URLEncoder.encode(mxid, "UTF-8")
|
||||
val url = "$homeserver/_matrix/client/v3/profile/$encodedId/displayname"
|
||||
val url = "$homeserver/_matrix/client/v3/profile/$encodedId"
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
val name = try {
|
||||
val (displayName, avatarMxc) = try {
|
||||
conn.requestMethod = "GET"
|
||||
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
@@ -190,22 +220,73 @@ class MatrixSyncService : Service() {
|
||||
conn.readTimeout = 4_000
|
||||
if (conn.responseCode == 200) {
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
JSONObject(body).optString("displayname").takeIf { it.isNotBlank() } ?: fallback
|
||||
val obj = JSONObject(body)
|
||||
val name = obj.optString("displayname").takeIf { it.isNotBlank() } ?: fallback
|
||||
val mxc = obj.optString("avatar_url").takeIf { it.startsWith("mxc://") }
|
||||
Pair(name, mxc)
|
||||
} else {
|
||||
fallback
|
||||
Pair(fallback, null)
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
displayNameCache[mxid] = name
|
||||
name
|
||||
displayNameCache[mxid] = displayName
|
||||
val avatar = avatarMxc
|
||||
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
|
||||
?.let { downloadBitmap(it, token) }
|
||||
avatarCache[mxid] = avatar
|
||||
UserProfile(displayName, avatar)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not resolve display name for $mxid: ${e.message}")
|
||||
fallback
|
||||
Log.w(TAG, "Could not resolve profile for $mxid: ${e.message}")
|
||||
displayNameCache[mxid] = fallback
|
||||
avatarCache[mxid] = null
|
||||
UserProfile(fallback, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMessageNotification(nm: NotificationManager, sender: String, body: String) {
|
||||
private fun mxcToThumbnailUrl(mxcUrl: String, homeserver: String, size: Int): String? {
|
||||
val withoutScheme = mxcUrl.removePrefix("mxc://")
|
||||
val slash = withoutScheme.indexOf('/')
|
||||
if (slash < 0) return null
|
||||
val serverName = withoutScheme.substring(0, slash)
|
||||
val mediaId = withoutScheme.substring(slash + 1)
|
||||
return "$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
|
||||
}
|
||||
|
||||
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? {
|
||||
val withoutScheme = mxcUrl.removePrefix("mxc://")
|
||||
val slash = withoutScheme.indexOf('/')
|
||||
if (slash < 0) return null
|
||||
val serverName = withoutScheme.substring(0, slash)
|
||||
val mediaId = withoutScheme.substring(slash + 1)
|
||||
return "$homeserver/_matrix/media/v3/download/$serverName/$mediaId"
|
||||
}
|
||||
|
||||
private fun downloadBitmap(urlString: String, token: String): Bitmap? {
|
||||
return try {
|
||||
val conn = URL(urlString).openConnection() as HttpURLConnection
|
||||
try {
|
||||
conn.requestMethod = "GET"
|
||||
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||
conn.connectTimeout = 5_000
|
||||
conn.readTimeout = 10_000
|
||||
if (conn.responseCode == 200) BitmapFactory.decodeStream(conn.inputStream) else null
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to download bitmap from $urlString: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMessageNotification(
|
||||
nm: NotificationManager,
|
||||
sender: String,
|
||||
body: String,
|
||||
largeIcon: Bitmap? = null,
|
||||
inlineImage: Bitmap? = null,
|
||||
) {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
?: Intent(this, MainActivity::class.java)
|
||||
val pi = PendingIntent.getActivity(
|
||||
@@ -213,16 +294,25 @@ class MatrixSyncService : Service() {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
|
||||
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||
.setContentTitle(sender)
|
||||
.setContentText(body)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pi)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.build()
|
||||
|
||||
nm.notify(System.currentTimeMillis().toInt(), notification)
|
||||
if (largeIcon != null) builder.setLargeIcon(largeIcon)
|
||||
|
||||
if (inlineImage != null) {
|
||||
builder.setStyle(
|
||||
NotificationCompat.BigPictureStyle()
|
||||
.bigPicture(inlineImage)
|
||||
.bigLargeIcon(null as Bitmap?)
|
||||
)
|
||||
}
|
||||
|
||||
nm.notify(System.currentTimeMillis().toInt(), builder.build())
|
||||
}
|
||||
|
||||
private fun buildStatusNotification(): Notification {
|
||||
@@ -258,10 +348,20 @@ class MatrixSyncService : Service() {
|
||||
|
||||
private fun ensureMessageChannel(nm: NotificationManager) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val soundUri = Uri.parse("android.resource://$packageName/${R.raw.paarrot_notification}")
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_MESSAGES, "Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply { description = "Matrix message notifications" }
|
||||
).apply {
|
||||
description = "Matrix message notifications"
|
||||
setSound(
|
||||
soundUri,
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
@@ -278,7 +378,7 @@ class MatrixSyncService : Service() {
|
||||
private const val KEY_LAST_WAKE_MS = "last_wake_ms"
|
||||
private const val NOTIF_ID_STATUS = 1001
|
||||
private const val CHANNEL_STATUS = "sync_status"
|
||||
private const val CHANNEL_MESSAGES = "messages"
|
||||
private const val CHANNEL_MESSAGES = "messages_paarrot"
|
||||
private const val MIN_WAKE_INTERVAL_MS = 7_500L
|
||||
const val MODE_ONE_SHOT = "one_shot"
|
||||
|
||||
|
||||
BIN
android/app/src/main/res/raw/paarrot_notification.ogg
Normal file
BIN
android/app/src/main/res/raw/paarrot_notification.ogg
Normal file
Binary file not shown.
2
cinny
2
cinny
Submodule cinny updated: ef0aa2a847...7ef9939d8a
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.90",
|
||||
"version": "4.11.94",
|
||||
"description": "Paarrot - A Matrix client based on Cinny",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
Reference in New Issue
Block a user