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 = 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, avatarBitmap: Bitmap?) { 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() + val shortcutId = "conversation_${user.id}_$roomToken" + + val personBuilder = Person.Builder() .setKey(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, 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