diff --git a/mobile/src/foss/java/org/openhab/habdroid/core/NotificationPoller.kt b/mobile/src/foss/java/org/openhab/habdroid/core/NotificationPoller.kt index 9f187e7f38..1a362f8514 100644 --- a/mobile/src/foss/java/org/openhab/habdroid/core/NotificationPoller.kt +++ b/mobile/src/foss/java/org/openhab/habdroid/core/NotificationPoller.kt @@ -52,7 +52,7 @@ object NotificationPoller { val lastSeenMessageId = prefs.getString(PrefKeys.FOSS_LAST_SEEN_MESSAGE, null) prefs.edit { - val newestSeenId = messages.firstOrNull()?.id ?: lastSeenMessageId + val newestSeenId = messages.firstOrNull()?.id?.persistedId ?: lastSeenMessageId putString(PrefKeys.FOSS_LAST_SEEN_MESSAGE, newestSeenId) } if (lastSeenMessageId == null) { @@ -61,7 +61,7 @@ object NotificationPoller { return } - val lastSeenIndex = messages.map { msg -> msg.id }.indexOf(lastSeenMessageId) + val lastSeenIndex = messages.map { msg -> msg.id.persistedId }.indexOf(lastSeenMessageId) val newMessages = if (lastSeenIndex >= 0) messages.subList(0, lastSeenIndex) else messages val notifHelper = NotificationHelper(context) diff --git a/mobile/src/full/java/org/openhab/habdroid/core/FcmMessageListenerService.kt b/mobile/src/full/java/org/openhab/habdroid/core/FcmMessageListenerService.kt index 69a522da46..07a0ed34cf 100644 --- a/mobile/src/full/java/org/openhab/habdroid/core/FcmMessageListenerService.kt +++ b/mobile/src/full/java/org/openhab/habdroid/core/FcmMessageListenerService.kt @@ -18,7 +18,12 @@ import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import kotlinx.coroutines.runBlocking import org.openhab.habdroid.model.CloudNotification +import org.openhab.habdroid.model.CloudNotificationAction +import org.openhab.habdroid.model.CloudNotificationId +import org.openhab.habdroid.model.toCloudNotificationAction import org.openhab.habdroid.model.toOH2IconResource +import org.openhab.habdroid.util.map +import org.openhab.habdroid.util.toJsonArrayOrNull class FcmMessageListenerService : FirebaseMessagingService() { private lateinit var notifHelper: NotificationHelper @@ -42,15 +47,23 @@ class FcmMessageListenerService : FirebaseMessagingService() { when (messageType) { "notification" -> { + val actions = data["actions"] + ?.toJsonArrayOrNull() + ?.map { it.toCloudNotificationAction() } + ?.filterNotNull() val cloudNotification = CloudNotification( - data["persistedId"].orEmpty(), - data["message"].orEmpty(), + id = CloudNotificationId(data["persistedId"].orEmpty(), data["reference-id"]), + title = data["title"].orEmpty(), + message = data["message"].orEmpty(), // Older versions of openhab-cloud didn't send the notification generation // timestamp, so use the (undocumented) google.sent_time as a time reference // in that case. If that also isn't present, don't show time at all. - data["timestamp"]?.toLong() ?: message.sentTime, - data["icon"].toOH2IconResource(), - data["severity"] + createdTimestamp = data["timestamp"]?.toLong() ?: message.sentTime, + icon = data["icon"].toOH2IconResource(), + tag = data["tag"], + actions = actions, + onClickAction = data["on-click"]?.let { CloudNotificationAction("", it) }, + mediaAttachmentUrl = data["media-attachment-url"] ) runBlocking { @@ -58,7 +71,9 @@ class FcmMessageListenerService : FirebaseMessagingService() { } } "hideNotification" -> { - notifHelper.cancelNotification(data["persistedId"].orEmpty().hashCode()) + data["tag"]?.let { tag -> notifHelper.cancelNotificationsByTag(tag) } + val id = CloudNotificationId(data["persistedId"].orEmpty(), data["reference-id"]) + notifHelper.cancelNotificationById(id) } } } diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 7aa02205c6..99e8e836ee 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -277,7 +277,7 @@ android:name=".background.CopyToClipboardReceiver" android:exported="false" /> { + val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0) + Log.d(TAG, "Dismissed notification $notificationId") + NotificationHelper(context).handleNotificationDismissed(notificationId) + } + ACTION_NOTIF_ACTION -> { + val cna = IntentCompat.getParcelableExtra( + intent, + EXTRA_NOTIFICATION_ACTION, + CloudNotificationAction::class.java + ) ?: return + val notificationId = IntentCompat.getParcelableExtra( + intent, + EXTRA_NOTIFICATION_ID, + CloudNotificationId::class.java + ) ?: return + Log.d(TAG, "Received action from $notificationId: $cna") + + when (val action = cna.action) { + is CloudNotificationAction.Action.ItemCommandAction -> + BackgroundTasksManager.enqueueNotificationAction(context, action) + is CloudNotificationAction.Action.UrlAction -> + action.url.toUri().openInBrowser(context) + is CloudNotificationAction.Action.NoAction -> { + // no-op + } + else -> { + throw IllegalArgumentException("Got unexpected action: $action") + } + } + NotificationHelper(context).cancelNotificationById(notificationId) + } + } + } + + companion object { + private val TAG = NotificationHandlingReceiver::class.java.simpleName + + const val ACTION_DISMISSED = "${BuildConfig.APPLICATION_ID}.action.NOTIFICATION_DISMISSED" + const val ACTION_NOTIF_ACTION = "${BuildConfig.APPLICATION_ID}.action.NOTIFICATION_ACTION" + + const val EXTRA_NOTIFICATION_ID = "notification_id" + const val EXTRA_NOTIFICATION_ACTION = "notification_action" + + fun createDismissedPendingIntent(context: Context, notificationId: Int): PendingIntent { + val intent = Intent(context, NotificationHandlingReceiver::class.java).apply { + action = ACTION_DISMISSED + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + return PendingIntent.getBroadcast( + context, + notificationId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent_Immutable + ) + } + + fun createActionPendingIntent( + context: Context, + notificationId: CloudNotificationId, + cna: CloudNotificationAction + ): PendingIntent = when (val cnaAction = cna.action) { + is CloudNotificationAction.Action.UiCommandAction -> { + val intent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_NEW_TASK + putExtra(MainActivity.EXTRA_UI_COMMAND, cnaAction.command) + putExtra(MainActivity.EXTRA_CLOUD_NOTIFICATION_ID, notificationId) + } + PendingIntent.getActivity( + context, + notificationId.notificationId + cna.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent_Immutable + ) + } + else -> { + val intent = Intent(context, NotificationHandlingReceiver::class.java).apply { + action = ACTION_NOTIF_ACTION + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + putExtra(EXTRA_NOTIFICATION_ACTION, cna) + } + PendingIntent.getBroadcast( + context, + notificationId.notificationId + cna.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent_Immutable + ) + } + } + } +} diff --git a/mobile/src/main/java/org/openhab/habdroid/core/NotificationHelper.kt b/mobile/src/main/java/org/openhab/habdroid/core/NotificationHelper.kt index ecebfe8c97..ccb8db3601 100644 --- a/mobile/src/main/java/org/openhab/habdroid/core/NotificationHelper.kt +++ b/mobile/src/main/java/org/openhab/habdroid/core/NotificationHelper.kt @@ -25,11 +25,13 @@ import android.os.Build import android.service.notification.StatusBarNotification import android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import org.openhab.habdroid.R import org.openhab.habdroid.background.NotificationUpdateObserver import org.openhab.habdroid.core.connection.ConnectionFactory import org.openhab.habdroid.model.CloudNotification +import org.openhab.habdroid.model.CloudNotificationId import org.openhab.habdroid.model.IconResource import org.openhab.habdroid.ui.MainActivity import org.openhab.habdroid.util.HttpClient @@ -46,64 +48,72 @@ class NotificationHelper(private val context: Context) { private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager suspend fun showNotification(message: CloudNotification) { - createChannelForSeverity(message.severity) - val n = makeNotification(message, message.idHash, createDeleteIntent(message.idHash)) - notificationManager.notify(message.idHash, n) + createChannelForTag(message.tag) + val n = makeNotification(message) + notificationManager.notify(message.id.notificationId, n) updateGroupNotification() } - fun cancelNotification(notificationId: Int) { - notificationManager.cancel(notificationId) + fun cancelNotificationById(id: CloudNotificationId) { + notificationManager.cancel(id.notificationId) if (HAS_GROUPING_SUPPORT) { val active = notificationManager.activeNotifications - if (notificationId != SUMMARY_NOTIFICATION_ID && countCloudNotifications(active) == 0) { + if (countCloudNotifications(active) == 0) { // Cancel summary when removing the last sub-notification notificationManager.cancel(SUMMARY_NOTIFICATION_ID) - } else if (notificationId == SUMMARY_NOTIFICATION_ID) { - // Cancel all sub-notifications when removing the summary - for (n in active) { - notificationManager.cancel(n.id) - } } else { updateGroupNotification() } } } - fun updateGroupNotification() { + fun cancelNotificationsByTag(tag: String) { + val channelId = getChannelId(tag) + NotificationManagerCompat.from(context) + .activeNotifications + .filter { sbn -> NotificationCompat.getChannelId(sbn.notification) == channelId } + .forEach { sbn -> notificationManager.cancel(sbn.id) } + } + + fun handleNotificationDismissed(notificationId: Int) { + if (!HAS_GROUPING_SUPPORT) { + return + } + if (notificationId == SUMMARY_NOTIFICATION_ID) { + // Cancel all sub-notifications when removing the summary + notificationManager.activeNotifications.forEach { notificationManager.cancel(it.id) } + } else { + updateGroupNotification() + } + } + + private fun updateGroupNotification() { if (!HAS_GROUPING_SUPPORT) { return } val count = countCloudNotifications(notificationManager.activeNotifications) if (count > 1) { + val deleteIntent = NotificationHandlingReceiver.createDismissedPendingIntent( + context, + SUMMARY_NOTIFICATION_ID + ) notificationManager.notify( SUMMARY_NOTIFICATION_ID, - makeSummaryNotification(count, System.currentTimeMillis(), createDeleteIntent(SUMMARY_NOTIFICATION_ID)) + makeSummaryNotification(count, System.currentTimeMillis(), deleteIntent) ) } } - private fun createDeleteIntent(notificationId: Int): PendingIntent { - val intent = Intent(context, NotificationDismissedReceiver::class.java) - intent.putExtra(NOTIFICATION_ID_EXTRA, notificationId) - return PendingIntent.getBroadcast( - context, - notificationId, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent_Immutable - ) - } - - private fun createChannelForSeverity(severity: String?) { + private fun createChannelForTag(tag: String?) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return } NotificationUpdateObserver.createNotificationChannels(context) - if (!severity.isNullOrEmpty()) { + if (!tag.isNullOrEmpty()) { with( NotificationChannel( - getChannelId(severity), - context.getString(R.string.notification_channel_severity_value, severity), + getChannelId(tag), + context.getString(R.string.notification_channel_severity_value, tag), NotificationManager.IMPORTANCE_DEFAULT ) ) { @@ -112,7 +122,7 @@ class NotificationHelper(private val context: Context) { enableLights(true) lightColor = ContextCompat.getColor(context, R.color.openhab_orange) group = NotificationUpdateObserver.CHANNEL_GROUP_MESSAGES - description = context.getString(R.string.notification_channel_severity_value_description, severity) + description = context.getString(R.string.notification_channel_severity_value_description, tag) notificationManager.createNotificationChannel(this) } } @@ -123,15 +133,19 @@ class NotificationHelper(private val context: Context) { return active.count { n -> n.id != 0 && (n.groupKey?.endsWith("gcm") == true) } } - private suspend fun makeNotification( - message: CloudNotification, - notificationId: Int, - deleteIntent: PendingIntent? - ): Notification { + private suspend fun makeNotification(message: CloudNotification): Notification { val iconBitmap = getNotificationIcon(message.icon) - val contentIntent = makeNotificationClickIntent(message.id, notificationId) - val channelId = getChannelId(message.severity) + val contentIntent = if (message.onClickAction == null) { + makeNotificationClickIntent(message.id, message.id.notificationId) + } else { + NotificationHandlingReceiver.createActionPendingIntent(context, message.id, message.onClickAction) + } + val deleteIntent = NotificationHandlingReceiver.createDismissedPendingIntent( + context, + message.id.notificationId + ) + val channelId = getChannelId(message.tag) val publicText = context.resources.getQuantityString(R.plurals.summary_notification_text, 1, 1) val publicVersion = makeNotificationBuilder(channelId, message.createdTimestamp) @@ -140,17 +154,38 @@ class NotificationHelper(private val context: Context) { .setContentIntent(contentIntent) .build() - return makeNotificationBuilder(channelId, message.createdTimestamp) + val builder = makeNotificationBuilder(channelId, message.createdTimestamp) .setLargeIcon(iconBitmap) - .setStyle(NotificationCompat.BigTextStyle().bigText(message.message)) .setSound(context.getPrefs().getNotificationTone()) + .setContentTitle(message.title) .setContentText(message.message) - .setSubText(message.severity) + .setSubText(message.tag) .setContentIntent(contentIntent) .setDeleteIntent(deleteIntent) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setPublicVersion(publicVersion) - .build() + + val messageImage = if (message.mediaAttachmentUrl != null) { + ConnectionFactory.waitForInitialization() + ConnectionFactory.primaryUsableConnection?.connection?.let { + message.loadImage(it, context, context.resources.displayMetrics.widthPixels) + } + } else { + null + } + if (messageImage != null) { + builder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(messageImage)) + } else { + builder.setStyle(NotificationCompat.BigTextStyle().bigText(message.message)) + } + + message.actions?.forEach { + val pi = NotificationHandlingReceiver.createActionPendingIntent(context, message.id, it) + val action = NotificationCompat.Action(null, it.label, pi) + builder.addAction(action) + } + + return builder.build() } private suspend fun getNotificationIcon(icon: IconResource?): Bitmap? { @@ -220,11 +255,11 @@ class NotificationHelper(private val context: Context) { .build() } - private fun makeNotificationClickIntent(persistedId: String?, notificationId: Int): PendingIntent { + private fun makeNotificationClickIntent(id: CloudNotificationId?, notificationId: Int): PendingIntent { val contentIntent = Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_NOTIFICATION_SELECTED flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra(MainActivity.EXTRA_PERSISTED_NOTIFICATION_ID, persistedId) + putExtra(MainActivity.EXTRA_PERSISTED_NOTIFICATION_ID, id?.persistedId) } return PendingIntent.getActivity( context, @@ -247,12 +282,11 @@ class NotificationHelper(private val context: Context) { companion object { private val TAG = NotificationHelper::class.java.simpleName - const val NOTIFICATION_ID_EXTRA = "notification_id" - private fun getChannelId(severity: String?) = if (severity.isNullOrEmpty()) { + private fun getChannelId(tag: String?) = if (tag.isNullOrEmpty()) { NotificationUpdateObserver.CHANNEL_ID_MESSAGE_DEFAULT } else { - "severity-$severity" + "severity-$tag" } internal const val SUMMARY_NOTIFICATION_ID = 0 diff --git a/mobile/src/main/java/org/openhab/habdroid/model/CloudNotification.kt b/mobile/src/main/java/org/openhab/habdroid/model/CloudNotification.kt index 6dc7d64e8f..a3c84d0e1f 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/CloudNotification.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/CloudNotification.kt @@ -13,25 +13,77 @@ package org.openhab.habdroid.model +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Parcelable +import android.util.Base64 import java.text.ParseException import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone import kotlinx.parcelize.Parcelize +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.json.JSONException import org.json.JSONObject +import org.openhab.habdroid.core.connection.Connection +import org.openhab.habdroid.util.IconBackground +import org.openhab.habdroid.util.ImageConversionPolicy +import org.openhab.habdroid.util.ItemClient +import org.openhab.habdroid.util.getIconFallbackColor +import org.openhab.habdroid.util.map import org.openhab.habdroid.util.optStringOrNull +@Parcelize +data class CloudNotificationId internal constructor( + val persistedId: String, + val referenceId: String? +) : Parcelable { + val notificationId get() = (referenceId ?: persistedId).hashCode() +} + @Parcelize data class CloudNotification internal constructor( - val id: String, + val id: CloudNotificationId, + val title: String, val message: String, val createdTimestamp: Long, val icon: IconResource?, - val severity: String? + val tag: String?, + val actions: List?, + val onClickAction: CloudNotificationAction?, + val mediaAttachmentUrl: String? ) : Parcelable { - val idHash get() = id.hashCode() + suspend fun loadImage(connection: Connection, context: Context, size: Int): Bitmap? { + if (mediaAttachmentUrl == null) { + return null + } + val itemStateFromMedia = if (mediaAttachmentUrl.startsWith("item:")) { + val itemName = mediaAttachmentUrl.removePrefix("item:") + val item = ItemClient.loadItem(connection, itemName) + item?.state?.asString + } else { + null + } + if (itemStateFromMedia != null && itemStateFromMedia.toHttpUrlOrNull() == null) { + // media attachment is an item, but item state is not a URL -> interpret as base64 encoded image + return bitmapFromBase64(itemStateFromMedia) + } + val fallbackColor = context.getIconFallbackColor(IconBackground.APP_THEME) + return connection.httpClient + .get(itemStateFromMedia ?: mediaAttachmentUrl) + .asBitmap(size, fallbackColor, ImageConversionPolicy.PreferTargetSize) + .response + } + + private fun bitmapFromBase64(itemState: String): Bitmap? { + return try { + val data = Base64.decode(itemState, Base64.DEFAULT) + BitmapFactory.decodeByteArray(data, 0, data.size) + } catch (e: IllegalArgumentException) { + null + } + } } @Throws(JSONException::class) @@ -47,11 +99,60 @@ fun JSONObject.toCloudNotification(): CloudNotification { } } + val payload = optJSONObject("payload") return CloudNotification( - getString("_id"), - getString("message"), - created, - optStringOrNull("icon").toOH2IconResource(), - optStringOrNull("severity") + id = CloudNotificationId(getString("_id"), payload?.optStringOrNull("reference-id")), + title = payload?.optString("title").orEmpty(), + message = payload?.getString("message") ?: getString("message"), + createdTimestamp = created, + icon = payload?.optStringOrNull("icon").toOH2IconResource() ?: optStringOrNull("icon").toOH2IconResource(), + tag = payload?.optStringOrNull("tag") ?: optStringOrNull("severity"), + actions = payload?.optJSONArray("actions")?.map { it.toCloudNotificationAction() }?.filterNotNull(), + onClickAction = payload?.optStringOrNull("on-click").toCloudNotificationAction(), + mediaAttachmentUrl = payload?.optStringOrNull("media-attachment-url") ) } + +@Parcelize +data class CloudNotificationAction internal constructor( + val label: String, + private val internalAction: String +) : Parcelable { + sealed class Action { + class UrlAction(val url: String) : Action() + class ItemCommandAction(val itemName: String, val command: String) : Action() + class UiCommandAction(val command: String) : Action() + object NoAction : Action() + } + + val action: Action get() { + val split = internalAction.split(":", limit = 3) + return when { + split[0] == "command" && split.size == 3 -> + Action.ItemCommandAction(split[1], split[2]) + internalAction.startsWith("http://") || internalAction.startsWith("https://") -> + Action.UrlAction(internalAction) + split[0] == "ui" && split.size == 3 -> { + Action.UiCommandAction("${split[1]}${split[2]}") + } + split[0] == "ui" && split.size == 2 -> { + Action.UiCommandAction("navigate:${split[1]}") + } + else -> Action.NoAction + } + } +} + +fun String?.toCloudNotificationAction(): CloudNotificationAction? { + val split = this?.split("=", limit = 2) + if (split?.size != 2) { + return null + } + return CloudNotificationAction(split.component1(), split.component2()) +} + +fun JSONObject?.toCloudNotificationAction(): CloudNotificationAction? { + val action = this?.optStringOrNull("action") ?: return null + val title = optStringOrNull("title") ?: return null + return CloudNotificationAction(title, action) +} diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/CloudNotificationAdapter.kt b/mobile/src/main/java/org/openhab/habdroid/ui/CloudNotificationAdapter.kt index 5226216313..174c10a59f 100644 --- a/mobile/src/main/java/org/openhab/habdroid/ui/CloudNotificationAdapter.kt +++ b/mobile/src/main/java/org/openhab/habdroid/ui/CloudNotificationAdapter.kt @@ -21,7 +21,11 @@ import android.widget.TextView import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView -import java.util.ArrayList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.openhab.habdroid.R import org.openhab.habdroid.core.connection.ConnectionFactory import org.openhab.habdroid.model.CloudNotification @@ -30,28 +34,43 @@ import org.openhab.habdroid.util.determineDataUsagePolicy class CloudNotificationAdapter(context: Context, private val loadMoreListener: () -> Unit) : RecyclerView.Adapter() { - private val items = ArrayList() + private val items = mutableListOf() + private val existingReferenceIds = mutableSetOf() private val inflater = LayoutInflater.from(context) private var hasMoreItems: Boolean = false private var waitingForMoreData: Boolean = false private var highlightedPosition = -1 - fun addLoadedItems(items: List, hasMoreItems: Boolean) { - this.items.addAll(items) + fun addLoadedItems(loaded: List, hasMoreItems: Boolean) { + val existingItemCount = items.size + val relevant = loaded.filter { + // Collapse multiple notifications with the same reference ID into the latest one by accepting either + // - notifications without reference ID or + // - notifications whose reference ID we haven't seen yet + it.id.referenceId == null || existingReferenceIds.add(it.id.referenceId) + } + items.addAll(relevant) + notifyItemRangeInserted(existingItemCount, relevant.size) + if (this.hasMoreItems && !hasMoreItems) { + notifyItemRemoved(items.size) + } else if (!this.hasMoreItems && hasMoreItems) { + notifyItemInserted(items.size) + } this.hasMoreItems = hasMoreItems waitingForMoreData = false - notifyDataSetChanged() } fun clear() { + val existingItemCount = itemCount items.clear() + existingReferenceIds.clear() hasMoreItems = false waitingForMoreData = false - notifyDataSetChanged() + notifyItemRangeRemoved(0, existingItemCount) } fun findPositionForId(id: String): Int { - return items.indexOfFirst { item -> item.id == id } + return items.indexOfFirst { item -> item.id.persistedId == id } } fun highlightItem(position: Int) { @@ -96,10 +115,12 @@ class CloudNotificationAdapter(context: Context, private val loadMoreListener: ( class NotificationViewHolder(inflater: LayoutInflater, parent: ViewGroup) : RecyclerView.ViewHolder(inflater.inflate(R.layout.notificationlist_item, parent, false)) { - private val createdView: TextView = itemView.findViewById(R.id.notificationCreated) + private val titleView: TextView = itemView.findViewById(R.id.notificationTitle) private val messageView: TextView = itemView.findViewById(R.id.notificationMessage) - private val iconView: WidgetImageView = itemView.findViewById(R.id.notificationImage) - private val severityView: TextView = itemView.findViewById(R.id.notificationSeverity) + private val createdView: TextView = itemView.findViewById(R.id.notificationCreated) + private val iconView: WidgetImageView = itemView.findViewById(R.id.notificationIcon) + private val imageView: WidgetImageView = itemView.findViewById(R.id.notificationImage) + private val tagView: TextView = itemView.findViewById(R.id.notificationTag) fun bind(notification: CloudNotification) { createdView.text = DateUtils.getRelativeDateTimeString( @@ -109,23 +130,36 @@ class CloudNotificationAdapter(context: Context, private val loadMoreListener: ( DateUtils.WEEK_IN_MILLIS, 0 ) + titleView.text = notification.title + titleView.isVisible = notification.title.isNotEmpty() messageView.text = notification.message + messageView.isVisible = notification.message.isNotEmpty() val conn = ConnectionFactory.activeCloudConnection?.connection - if (notification.icon != null && conn != null) { - iconView.setImageUrl( - conn, - notification.icon.toUrl( - itemView.context, - itemView.context.determineDataUsagePolicy(conn).loadIconsWithState - ), - timeoutMillis = 2000 - ) - } else { + if (conn == null) { iconView.applyFallbackDrawable() + imageView.isVisible = false + } else { + if (notification.icon != null) { + iconView.setImageUrl( + conn, + notification.icon.toUrl( + itemView.context, + itemView.context.determineDataUsagePolicy(conn).loadIconsWithState + ), + timeoutMillis = 2000 + ) + } + imageView.isVisible = notification.mediaAttachmentUrl != null + CoroutineScope(Dispatchers.IO + Job()).launch { + val bitmap = notification.loadImage(conn, itemView.context, itemView.width) + withContext(Dispatchers.Main) { + imageView.setImageBitmap(bitmap) + } + } } - severityView.text = notification.severity - severityView.isGone = notification.severity.isNullOrEmpty() + tagView.text = notification.tag + tagView.isGone = notification.tag.isNullOrEmpty() } } diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/MainActivity.kt b/mobile/src/main/java/org/openhab/habdroid/ui/MainActivity.kt index 77bbb6ecf3..d37d5d7ed6 100644 --- a/mobile/src/main/java/org/openhab/habdroid/ui/MainActivity.kt +++ b/mobile/src/main/java/org/openhab/habdroid/ui/MainActivity.kt @@ -50,6 +50,7 @@ import androidx.annotation.StringRes import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat import androidx.core.content.edit import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.toDrawable @@ -86,6 +87,7 @@ import org.openhab.habdroid.background.EventListenerService import org.openhab.habdroid.background.NotificationUpdateObserver import org.openhab.habdroid.background.PeriodicItemUpdateWorker import org.openhab.habdroid.core.CloudMessagingHelper +import org.openhab.habdroid.core.NotificationHelper import org.openhab.habdroid.core.OpenHabApplication import org.openhab.habdroid.core.UpdateBroadcastReceiver import org.openhab.habdroid.core.connection.CloudConnection @@ -96,6 +98,7 @@ import org.openhab.habdroid.core.connection.DemoConnection import org.openhab.habdroid.core.connection.NetworkNotAvailableException import org.openhab.habdroid.core.connection.NoUrlInformationException import org.openhab.habdroid.core.connection.WrongWifiException +import org.openhab.habdroid.model.CloudNotificationId import org.openhab.habdroid.model.LinkedPage import org.openhab.habdroid.model.ServerConfiguration import org.openhab.habdroid.model.ServerProperties @@ -140,6 +143,7 @@ import org.openhab.habdroid.util.isDebugModeEnabled import org.openhab.habdroid.util.isEventListenerEnabled import org.openhab.habdroid.util.isScreenTimerDisabled import org.openhab.habdroid.util.openInAppStore +import org.openhab.habdroid.util.orDefaultIfEmpty import org.openhab.habdroid.util.parcelable import org.openhab.habdroid.util.putActiveServerId import org.openhab.habdroid.util.resolveThemedColor @@ -827,8 +831,8 @@ class MainActivity : AbstractBaseActivity(), ConnectionFactory.UpdateListener { // Add a host here to be able to parse as HttpUrl val httpLink = "https://openhab.org$link".toHttpUrlOrNull() ?: return val sitemap = httpLink.queryParameter("sitemap") - ?: prefs.getDefaultSitemap(connection, serverId)?.name - val subpage = httpLink.queryParameter("w") + ?: prefs.getDefaultSitemap(connection, serverId)?.name ?: return + val subpage = httpLink.queryParameter("w").orDefaultIfEmpty(sitemap) executeOrStoreAction(PendingAction.OpenSitemapUrl("/$sitemap/$subpage", serverId)) } else { executeOrStoreAction(PendingAction.OpenWebViewUi(WebViewUi.MAIN_UI, serverId, link)) @@ -848,6 +852,20 @@ class MainActivity : AbstractBaseActivity(), ConnectionFactory.UpdateListener { handleLink(link, serverId) } + if (!intent.getStringExtra(EXTRA_UI_COMMAND).isNullOrEmpty()) { + val command = intent.getStringExtra(EXTRA_UI_COMMAND) ?: return + handleUiCommand(command, prefs.getPrimaryServerId()) + val notificationId = IntentCompat.getParcelableExtra( + intent, + EXTRA_CLOUD_NOTIFICATION_ID, + CloudNotificationId::class.java + ) + if (notificationId != null) { + // The invoking intent came from a notification click, so cancel the notification + NotificationHelper(this).cancelNotificationById(notificationId) + } + } + when (intent.action) { NfcAdapter.ACTION_NDEF_DISCOVERED, Intent.ACTION_VIEW -> { val tag = intent.data?.toTagData() @@ -1506,11 +1524,11 @@ class MainActivity : AbstractBaseActivity(), ConnectionFactory.UpdateListener { ItemClient.listenForItemChange(this, connection ?: return, item) { _, payload -> val state = payload.getString("value") Log.d(TAG, "Got state by event: $state") - handleUiCommand(state) + handleUiCommand(state, prefs.getActiveServerId()) } } - private fun handleUiCommand(command: String) { + private fun handleUiCommand(command: String, serverId: Int) { val prefix = command.substringBefore(":") val commandContent = command.removePrefix("$prefix:") when (prefix) { @@ -1538,7 +1556,7 @@ class MainActivity : AbstractBaseActivity(), ConnectionFactory.UpdateListener { } } } - "navigate" -> handleLink(commandContent, prefs.getActiveServerId()) + "navigate" -> handleLink(commandContent, serverId) "close" -> uiCommandItemNotification?.dismiss() "back" -> onBackPressedCallback.handleOnBackPressed() "reload" -> recreate() @@ -1645,6 +1663,8 @@ class MainActivity : AbstractBaseActivity(), ConnectionFactory.UpdateListener { const val EXTRA_SUBPAGE = "subpage" const val EXTRA_LINK = "link" const val EXTRA_PERSISTED_NOTIFICATION_ID = "persistedNotificationId" + const val EXTRA_UI_COMMAND = "uiCommand" + const val EXTRA_CLOUD_NOTIFICATION_ID = "cloudNotificationId" const val SNACKBAR_TAG_DEMO_MODE_ACTIVE = "demoModeActive" const val SNACKBAR_TAG_PRESS_AGAIN_EXIT = "pressAgainToExit" diff --git a/mobile/src/main/java/org/openhab/habdroid/util/ExtensionFuncs.kt b/mobile/src/main/java/org/openhab/habdroid/util/ExtensionFuncs.kt index 14675ef2fc..494b6c05f1 100644 --- a/mobile/src/main/java/org/openhab/habdroid/util/ExtensionFuncs.kt +++ b/mobile/src/main/java/org/openhab/habdroid/util/ExtensionFuncs.kt @@ -305,6 +305,12 @@ fun JSONObject.optStringOrFallback(key: String, fallback: String?): String? { return if (has(key)) getString(key) else fallback } +fun String.toJsonArrayOrNull() = try { + JSONArray(this) +} catch (e: Exception) { + null +} + fun Context.getPrefs(): SharedPreferences { return PreferenceManager.getDefaultSharedPreferences(this) } diff --git a/mobile/src/main/res/layout/notificationlist_item.xml b/mobile/src/main/res/layout/notificationlist_item.xml index 921e524f7f..7de0e2b464 100644 --- a/mobile/src/main/res/layout/notificationlist_item.xml +++ b/mobile/src/main/res/layout/notificationlist_item.xml @@ -1,63 +1,95 @@ - + android:minHeight="?attr/listPreferredItemHeight" + android:orientation="horizontal" + android:paddingHorizontal="16dp" + android:paddingTop="8dp" + android:paddingBottom="8dp"> - + + - - - - - - - - - - + android:layout_marginTop="8dp" + android:textAppearance="?attr/textAppearanceBodyLarge" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="@+id/guideline" + app:layout_constraintTop_toBottomOf="@+id/notificationTitle" + tools:text="Some notification" /> + + + + + + + + + +