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" />
+
+
+
+
+
+
+
+
+
+