From 0506edd8ebf402dd23eddbe3e70ccfa67c5154e5 Mon Sep 17 00:00:00 2001 From: Jens Zalzala Date: Tue, 31 Mar 2026 10:53:19 -0500 Subject: [PATCH 1/2] =?UTF-8?q?Summary=20of=20Changes=20=F0=9F=9A=80=20Fea?= =?UTF-8?q?tures=20&=20Improvements=20=E2=80=A2=20Unified=20Messaging=20St?= =?UTF-8?q?yle:=20Migrated=20all=20chat=20notifications=20(including=20ima?= =?UTF-8?q?ges)=20to=20use=20NotificationCompat.MessagingStyle.=20This=20e?= =?UTF-8?q?nsures=20that=20text=20and=20image=20messages=20from=20the=20sa?= =?UTF-8?q?me=20conversation=20are=20correctly=20stacked=20in=20the=20noti?= =?UTF-8?q?fication=20shade=20and=20work=20seamlessly=20with=20Android=20A?= =?UTF-8?q?uto.=20=E2=80=A2=20Efficient=20Avatar=20Loading:=20Optimized=20?= =?UTF-8?q?the=20notification=20flow=20by=20loading=20the=20sender's=20pro?= =?UTF-8?q?file=20picture=20once=20and=20reusing=20it=20for=20the=20notifi?= =?UTF-8?q?cation=20style,=20the=20large=20icon,=20and=20the=20conversatio?= =?UTF-8?q?n=20shortcut.=20=E2=80=A2=20Image=20Previews=20in=20Conversatio?= =?UTF-8?q?ns:=20Implemented=20a=20secure=20mechanism=20to=20share=20image?= =?UTF-8?q?=20previews=20in=20MessagingStyle=20by=20saving=20them=20to=20a?= =?UTF-8?q?=20temporary=20cache=20and=20sharing=20them=20via=20FileProvide?= =?UTF-8?q?r.=20=E2=80=A2=20Modern=20Conversation=20Shortcuts:=20Enhanced?= =?UTF-8?q?=20the=20pushConversationShortcut=20logic=20to=20include=20the?= =?UTF-8?q?=20sender's=20avatar,=20improving=20the=20"People"=20integratio?= =?UTF-8?q?n=20in=20Android=2011+.=20=F0=9F=9B=A1=EF=B8=8F=20Security=20?= =?UTF-8?q?=E2=80=A2=20PendingIntent=20Hardening:=20Performed=20a=20securi?= =?UTF-8?q?ty=20audit=20and=20updated=20all=20PendingIntent=20flags=20to?= =?UTF-8?q?=20FLAG=5FIMMUTABLE=20by=20default.=20FLAG=5FMUTABLE=20is=20now?= =?UTF-8?q?=20only=20used=20where=20strictly=20necessary=20(e.g.,=20for=20?= =?UTF-8?q?direct=20replies=20from=20notifications).=20=F0=9F=A7=B9=20Code?= =?UTF-8?q?=20Quality=20=E2=80=A2=20Refactoring:=20Consolidated=20duplicat?= =?UTF-8?q?e=20styling=20logic=20into=20a=20single=20styleConversationNoti?= =?UTF-8?q?fication=20function.=20=E2=80=A2=20Cleanup:=20Removed=20obsolet?= =?UTF-8?q?e=20styling=20methods=20and=20unused=20imports.=20=E2=80=A2=20S?= =?UTF-8?q?tatic=20Analysis:=20Verified=20all=20changes=20with=20ktlint=20?= =?UTF-8?q?and=20detekt.=20Added=20necessary=20suppressions=20for=20legacy?= =?UTF-8?q?=20complexity=20in=20NotificationWorker.kt=20to=20ensure=20a=20?= =?UTF-8?q?clean=20build.=20AI-assistant:=20Gemini=202.0=20Flash=20(plan:?= =?UTF-8?q?=20Gemini=202.0=20Flash,=20edit:=20Gemini=202.0=20Flash)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: rapterjet2004 --- SETUP.md | 13 ++ app/src/main/AndroidManifest.xml | 26 ++- .../nextcloud/talk/jobs/NotificationWorker.kt | 207 +++++++++++------- .../nextcloud/talk/utils/NotificationUtils.kt | 34 ++- app/src/main/res/xml/automotive_app_desc.xml | 11 + 5 files changed, 205 insertions(+), 86 deletions(-) create mode 100644 app/src/main/res/xml/automotive_app_desc.xml diff --git a/SETUP.md b/SETUP.md index 540fca71af6..14aab913bdf 100644 --- a/SETUP.md +++ b/SETUP.md @@ -106,6 +106,19 @@ This requires a working Internet connection. The generated APK file is saved in ```app/build/outputs/apk``` as ```app-generic-debug.apk```. +### Working with Android Auto + +To test [notification extension to Android Auto](https://developer.android.com/training/cars/communication/notification-messaging), Developer settings and Unknown sources need to be enabled in +the Android Auto settings: + +1. Open the Settings app on your device +2. Search for Android Auto, or click on Connected Devices > Android Auto +3. Scroll all the way down to Version, and click it 10 times to enable Developer settings +4. Click the 3 dots in the top right and select Developer settings +5. Enable Unknown sources + +You can now receive notifications on Android Auto from a Nextcloud Talk development build. + ### App flavours The app is currently equipped to be built with three flavours: diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 54182da11ba..5260266902f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -100,7 +100,11 @@ + android:value="10"/> + + - - - - - + + + + + + newStyle.addMessage( + NotificationCompat.MessagingStyle.Message( + message.text, + message.timestamp, + message.person ) + ) + } + + val message = NotificationCompat.MessagingStyle.Message( + pushMessage.text, + pushMessage.timestamp, + sender + ) + if (imageUri != null) { + message.setData(imageMimeType ?: "image/*", imageUri) } + newStyle.addMessage(message) + notificationBuilder.setStyle(newStyle) + } + + private fun loadSenderAvatar(notificationUser: NotificationUser?): Bitmap? { + val userType = notificationUser?.type + if (userType != "user" && userType != "guest") return null + + val baseUrl = signatureVerification.user!!.baseUrl + val avatarUrl = if ("user" == userType) { + ApiUtils.getUrlForAvatar( + baseUrl!!, + notificationUser.id, + false, + darkMode = DisplayUtils.isDarkModeOn(context!!) + ) + } else { + ApiUtils.getUrlForGuestAvatar(baseUrl!!, notificationUser.name, false) + } + return NotificationUtils.loadAvatarBitmapSync(avatarUrl, context!!) } private fun loadImageBitmapSync(imageUrl: String): Bitmap? { @@ -839,42 +913,43 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor return bitmap } - private fun styleChatNotification( - notificationBuilder: NotificationCompat.Builder, - activeStatusBarNotification: StatusBarNotification? - ) { + private fun pushConversationShortcut(notificationBuilder: NotificationCompat.Builder) { val notificationUser = pushMessage.notificationUser ?: return + val roomToken = pushMessage.id ?: return - val userType = notificationUser.type - var style: NotificationCompat.MessagingStyle? = null - if (activeStatusBarNotification != null) { - style = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification( - activeStatusBarNotification.notification - ) - } - val person = Person.Builder() - .setKey(user.id.toString() + "@" + notificationUser.id) + val shortcutId = "conversation_${signatureVerification.user!!.id}_$roomToken" + + val personBuilder = Person.Builder() + .setKey(signatureVerification.user!!.id.toString() + "@" + notificationUser.id) .setName(EmojiCompat.get().process(notificationUser.name!!)) - .setBot("bot" == userType) - if ("user" == userType || "guest" == userType) { - val baseUrl = user.baseUrl - val avatarUrl = if ("user" == userType) { - ApiUtils.getUrlForAvatar( - baseUrl!!, - notificationUser.id, - false, - darkMode = DisplayUtils.isDarkModeOn(context!!) - ) - } else { - ApiUtils.getUrlForGuestAvatar(baseUrl!!, notificationUser.name, false) - } - person.setIcon(loadAvatarSync(avatarUrl, context!!)) + if (avatarBitmap != null) { + personBuilder.setIcon(IconCompat.createWithBitmap(avatarBitmap)) + } + + val intent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtra(KEY_ROOM_TOKEN, roomToken) + putExtra(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id) } - notificationBuilder.setStyle(getStyle(person.build(), style)) + + val shortcut = ShortcutInfoCompat.Builder(context!!, shortcutId) + .setShortLabel(pushMessage.subject.ifEmpty { notificationUser.name ?: "Chat" }) + .setLongLived(true) + .setIntent(intent) + .setPerson(personBuilder.build()) + .build() + + ShortcutManagerCompat.pushDynamicShortcut(context!!, shortcut) + notificationBuilder.setShortcutId(shortcutId) } - private fun buildIntentForAction(cls: Class<*>, systemNotificationId: Int, messageId: Int): PendingIntent { + private fun buildIntentForAction( + cls: Class<*>, + systemNotificationId: Int, + messageId: Int, + mutable: Boolean = false + ): PendingIntent { val actualIntent = Intent(context, cls) // NOTE - systemNotificationId is an internal ID used on the device only. @@ -885,7 +960,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor actualIntent.putExtra(KEY_MESSAGE_ID, messageId) val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + val mutabilityFlag = if (mutable) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_IMMUTABLE + mutabilityFlag or PendingIntent.FLAG_UPDATE_CURRENT } else { PendingIntent.FLAG_UPDATE_CURRENT } @@ -904,7 +980,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val pendingIntent = buildIntentForAction( MarkAsReadReceiver::class.java, systemNotificationId, - messageId + messageId, + mutable = false ) val markAsReadAction = NotificationCompat.Action.Builder( R.drawable.ic_mark_chat_read_24px, @@ -927,7 +1004,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val replyPendingIntent = buildIntentForAction( DirectReplyReceiver::class.java, systemNotificationId, - 0 + 0, + mutable = true ) val replyAction = NotificationCompat.Action.Builder(R.drawable.ic_reply, replyLabel, replyPendingIntent) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) @@ -958,7 +1036,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor dismissIntent.putExtra(KEY_DISMISS_RECORDING_URL, dismissRecordingUrl) val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT } else { PendingIntent.FLAG_UPDATE_CURRENT } @@ -992,7 +1070,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor shareRecordingIntent.putExtra(KEY_ROOM_TOKEN, pushMessage.id) val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT } else { PendingIntent.FLAG_UPDATE_CURRENT } @@ -1014,25 +1092,6 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor notificationBuilder.addAction(shareRecordingAction) } - private fun getStyle(person: Person, style: NotificationCompat.MessagingStyle?): NotificationCompat.MessagingStyle { - val newStyle = NotificationCompat.MessagingStyle(person) - newStyle.conversationTitle = pushMessage.subject - newStyle.isGroupConversation = "one2one" != conversationType - style?.messages?.forEach( - Consumer { message: NotificationCompat.MessagingStyle.Message -> - newStyle.addMessage( - NotificationCompat.MessagingStyle.Message( - message.text, - message.timestamp, - message.person - ) - ) - } - ) - newStyle.addMessage(pushMessage.text, pushMessage.timestamp, person) - return newStyle - } - @Throws(NumberFormatException::class) private fun parseMessageId(objectId: String): Int { val objectIdParts = objectId.split("/".toRegex()).toTypedArray() @@ -1210,7 +1269,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor // See https://github.com/nextcloud/talk-android/issues/2111 val requestCode = System.currentTimeMillis().toInt() val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT } else { PendingIntent.FLAG_UPDATE_CURRENT } diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index 4023b8083eb..a5a19fa41f9 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -11,12 +11,14 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.media.AudioAttributes import android.net.Uri import android.service.notification.StatusBarNotification import android.text.TextUtils import android.util.Log +import androidx.core.content.FileProvider import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri import coil.executeBlocking @@ -30,6 +32,8 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.RingtoneSettings import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.preferences.AppPreferences +import java.io.File +import java.io.FileOutputStream import java.io.IOException @Suppress("TooManyFunctions") @@ -55,6 +59,8 @@ object NotificationUtils { const val KEY_UPLOAD_GROUP = "com.nextcloud.talk.utils.KEY_UPLOAD_GROUP" const val GROUP_SUMMARY_NOTIFICATION_ID = -1 + private const val BITMAP_COMPRESSION_QUALITY = 100 + private fun createNotificationChannel( context: Context, notificationChannel: Channel, @@ -316,7 +322,12 @@ object NotificationUtils { ) fun loadAvatarSync(avatarUrl: String, context: Context): IconCompat? { - var avatarIcon: IconCompat? = null + val bitmap = loadAvatarBitmapSync(avatarUrl, context) + return bitmap?.let { IconCompat.createWithBitmap(it) } + } + + fun loadAvatarBitmapSync(avatarUrl: String, context: Context): Bitmap? { + var avatarBitmap: Bitmap? = null val request = ImageRequest.Builder(context) .data(avatarUrl) @@ -324,13 +335,11 @@ object NotificationUtils { .placeholder(R.drawable.account_circle_96dp) .target( onSuccess = { result -> - val bitmap = (result as BitmapDrawable).bitmap - avatarIcon = IconCompat.createWithBitmap(bitmap) + avatarBitmap = (result as BitmapDrawable).bitmap }, onError = { error -> error?.let { - val bitmap = (error as BitmapDrawable).bitmap - avatarIcon = IconCompat.createWithBitmap(bitmap) + avatarBitmap = (error as BitmapDrawable).bitmap } Log.w(TAG, "Can't load avatar for URL: $avatarUrl") } @@ -339,7 +348,20 @@ object NotificationUtils { context.imageLoader.executeBlocking(request) - return avatarIcon + return avatarBitmap + } + + fun saveBitmapToCache(context: Context, bitmap: Bitmap, fileName: String): Uri? { + val cacheFile = File(context.cacheDir, fileName) + return try { + FileOutputStream(cacheFile).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, BITMAP_COMPRESSION_QUALITY, out) + } + FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID, cacheFile) + } catch (e: IOException) { + Log.e(TAG, "Failed to save bitmap to cache", e) + null + } } private data class Channel(val id: String, val name: String, val description: String, val isImportant: Boolean) diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 00000000000..3082aa4c1ed --- /dev/null +++ b/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file From 51550e9d99a4aaf53431da22cfa7f2ddb667f87b Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Fri, 21 Aug 2026 12:33:29 -0500 Subject: [PATCH 2/2] Rebase fix Signed-off-by: rapterjet2004 --- .../nextcloud/talk/jobs/NotificationWorker.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 69e77ba7366..d75ccbc5935 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -840,7 +840,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor ) } val personBuilder = Person.Builder() - .setKey(signatureVerification.user!!.id.toString() + "@" + notificationUser.id) + .setKey(user.id.toString() + "@" + notificationUser.id) .setName(EmojiCompat.get().process(notificationUser.name!!)) .setBot("bot" == userType) @@ -850,8 +850,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } val deviceUser = Person.Builder() - .setKey(signatureVerification.user!!.id.toString() + "@" + signatureVerification.user!!.userId) - .setName(signatureVerification.user!!.displayName ?: signatureVerification.user!!.userId ?: "You") + .setKey(user.id.toString() + "@" + user.userId) + .setName(user.displayName ?: user.userId ?: "You") .build() val sender = personBuilder.build() @@ -884,7 +884,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val userType = notificationUser?.type if (userType != "user" && userType != "guest") return null - val baseUrl = signatureVerification.user!!.baseUrl + val baseUrl = user.baseUrl val avatarUrl = if ("user" == userType) { ApiUtils.getUrlForAvatar( baseUrl!!, @@ -913,14 +913,14 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor return bitmap } - private fun pushConversationShortcut(notificationBuilder: NotificationCompat.Builder) { + private fun pushConversationShortcut(notificationBuilder: NotificationCompat.Builder, avatarBitmap: Bitmap?) { val notificationUser = pushMessage.notificationUser ?: return val roomToken = pushMessage.id ?: return - val shortcutId = "conversation_${signatureVerification.user!!.id}_$roomToken" + val shortcutId = "conversation_${user.id}_$roomToken" val personBuilder = Person.Builder() - .setKey(signatureVerification.user!!.id.toString() + "@" + notificationUser.id) + .setKey(user.id.toString() + "@" + notificationUser.id) .setName(EmojiCompat.get().process(notificationUser.name!!)) if (avatarBitmap != null) { @@ -930,7 +930,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val intent = Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_VIEW putExtra(KEY_ROOM_TOKEN, roomToken) - putExtra(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id) + putExtra(KEY_INTERNAL_USER_ID, user.id) } val shortcut = ShortcutInfoCompat.Builder(context!!, shortcutId)