feat: enhance MatrixSyncService with user profile handling and image notifications
This commit is contained in:
@@ -9,6 +9,8 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -37,6 +39,11 @@ class MatrixSyncService : Service() {
|
|||||||
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
|
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
|
||||||
private val displayNameCache = HashMap<String, String>(16)
|
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 onBind(intent: Intent?) = null
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@@ -165,24 +172,45 @@ class MatrixSyncService : Service() {
|
|||||||
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
||||||
|
|
||||||
val content = event.optJSONObject("content") ?: continue
|
val content = event.optJSONObject("content") ?: continue
|
||||||
val body = content.optString("body").takeIf { it.isNotBlank() } ?: continue
|
val msgtype = content.optString("msgtype")
|
||||||
val sender = event.optString("sender")
|
val rawBody = content.optString("body")
|
||||||
val senderName = resolveDisplayName(sender, homeserver, token)
|
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 {
|
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
|
||||||
displayNameCache[mxid]?.let { return it }
|
val cachedName = displayNameCache[mxid]
|
||||||
|
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
|
||||||
|
|
||||||
val fallback = mxid.substringAfter("@").substringBefore(":")
|
val fallback = mxid.substringAfter("@").substringBefore(":")
|
||||||
return try {
|
return try {
|
||||||
val encodedId = URLEncoder.encode(mxid, "UTF-8")
|
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 conn = URL(url).openConnection() as HttpURLConnection
|
||||||
val name = try {
|
val (displayName, avatarMxc) = try {
|
||||||
conn.requestMethod = "GET"
|
conn.requestMethod = "GET"
|
||||||
conn.setRequestProperty("Authorization", "Bearer $token")
|
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||||
conn.setRequestProperty("Accept", "application/json")
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
@@ -190,22 +218,73 @@ class MatrixSyncService : Service() {
|
|||||||
conn.readTimeout = 4_000
|
conn.readTimeout = 4_000
|
||||||
if (conn.responseCode == 200) {
|
if (conn.responseCode == 200) {
|
||||||
val body = conn.inputStream.bufferedReader().readText()
|
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 {
|
} else {
|
||||||
fallback
|
Pair(fallback, null)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
conn.disconnect()
|
conn.disconnect()
|
||||||
}
|
}
|
||||||
displayNameCache[mxid] = name
|
displayNameCache[mxid] = displayName
|
||||||
name
|
val avatar = avatarMxc
|
||||||
|
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
|
||||||
|
?.let { downloadBitmap(it, token) }
|
||||||
|
avatarCache[mxid] = avatar
|
||||||
|
UserProfile(displayName, avatar)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Could not resolve display name for $mxid: ${e.message}")
|
Log.w(TAG, "Could not resolve profile for $mxid: ${e.message}")
|
||||||
fallback
|
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)
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
?: Intent(this, MainActivity::class.java)
|
?: Intent(this, MainActivity::class.java)
|
||||||
val pi = PendingIntent.getActivity(
|
val pi = PendingIntent.getActivity(
|
||||||
@@ -213,16 +292,25 @@ class MatrixSyncService : Service() {
|
|||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
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)
|
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||||
.setContentTitle(sender)
|
.setContentTitle(sender)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(pi)
|
.setContentIntent(pi)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.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 {
|
private fun buildStatusNotification(): Notification {
|
||||||
|
|||||||
Reference in New Issue
Block a user