Initial commit of notification support on Wear OS (#3221)

* Initial commit of wear OS notifications

* Update to allow message and title

* Move constants to object to reduce imports

* Split up logic to match phone app

* Update readme and set websocket to false for wear OS

* Bump firebase BOM
This commit is contained in:
Daniel Shokouhi 2023-01-20 11:02:03 -08:00 committed by GitHub
parent 5cd74c0179
commit 346ef3da5f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 538 additions and 305 deletions

View file

@ -17,7 +17,7 @@ If you are looking for documentation around the companion applications check out
- `io.homeassistant.companion.android.minimal` - `io.homeassistant.companion.android.minimal`
- `io.homeassistant.companion.android.minimal.debug` - `io.homeassistant.companion.android.minimal.debug`
5. Now download the `google-services.json` file and put it in the _home-assistant-Android/app_ folder. This file contains the configuration of the whole project (all four applications). ([You can also use the mock services file instead of generating your own](/.github/mock-google-services.json). The file should contain client IDs for all packages listed above for debugging to work properly. **If you do not generate your own file FCM push notification will never work, only websocket notifications will**) 5. Now download the `google-services.json` file and put it in the _home-assistant-Android/app_ and _home-assistant-Android/wear_ folder. This file contains the configuration of the whole project (all four applications). ([You can also use the mock services file instead of generating your own](/.github/mock-google-services.json). The file should contain client IDs for all packages listed above for debugging to work properly. **If you do not generate your own file FCM push notification will never work, only websocket notifications will**)
6. Start Android Studio, open your source code folder and check if the Gradle build will be successful using Build/Make Module "App". You might have to install the right Android SDK via Tools/SDK Manager first. 6. Start Android Studio, open your source code folder and check if the Gradle build will be successful using Build/Make Module "App". You might have to install the right Android SDK via Tools/SDK Manager first.
7. Run `gradlew assembleDebug` to build all debug versions, this might take a while. 7. Run `gradlew assembleDebug` to build all debug versions, this might take a while.
8. If the build is successful, you can run the app by doing the following: click **Run** -> **Run 'app'**. 8. If the build is successful, you can run the app by doing the following: click **Run** -> **Run 'app'**.

View file

@ -143,9 +143,6 @@ dependencies {
implementation("org.altbeacon:android-beacon-library:2.19.5") implementation("org.altbeacon:android-beacon-library:2.19.5")
implementation("com.maltaisn:icondialog:3.3.0") implementation("com.maltaisn:icondialog:3.3.0")
implementation("com.maltaisn:iconpack-community-material:5.3.45") implementation("com.maltaisn:iconpack-community-material:5.3.45")
implementation("com.vdurmont:emoji-java:5.1.1") {
exclude(group = "org.json", module = "json")
}
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.20") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.20")
implementation("org.jetbrains.kotlin:kotlin-reflect:1.7.20") implementation("org.jetbrains.kotlin:kotlin-reflect:1.7.20")
@ -171,7 +168,7 @@ dependencies {
"fullImplementation"("com.google.android.gms:play-services-location:21.0.1") "fullImplementation"("com.google.android.gms:play-services-location:21.0.1")
"fullImplementation"("com.google.android.gms:play-services-home:16.0.0") "fullImplementation"("com.google.android.gms:play-services-home:16.0.0")
"fullImplementation"(platform("com.google.firebase:firebase-bom:30.4.1")) "fullImplementation"(platform("com.google.firebase:firebase-bom:31.1.1"))
"fullImplementation"("com.google.firebase:firebase-messaging") "fullImplementation"("com.google.firebase:firebase-messaging")
"fullImplementation"("io.sentry:sentry-android:6.11.0") "fullImplementation"("io.sentry:sentry-android:6.11.0")
"fullImplementation"("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.4") "fullImplementation"("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.4")

View file

@ -13,7 +13,6 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Color
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioManager import android.media.AudioManager
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
@ -29,7 +28,6 @@ import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
import android.text.Spanned
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.widget.RemoteViews import android.widget.RemoteViews
@ -41,22 +39,27 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.text.HtmlCompat
import androidx.core.text.isDigitsOnly import androidx.core.text.isDigitsOnly
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.utils.toAndroidIconCompat
import com.vdurmont.emoji.EmojiParser
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.homeassistant.companion.android.R import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.common.data.url.UrlRepository import io.homeassistant.companion.android.common.data.url.UrlRepository
import io.homeassistant.companion.android.common.notifications.NotificationData
import io.homeassistant.companion.android.common.notifications.createChannelID
import io.homeassistant.companion.android.common.notifications.getGroupNotificationBuilder
import io.homeassistant.companion.android.common.notifications.handleChannel
import io.homeassistant.companion.android.common.notifications.handleColor
import io.homeassistant.companion.android.common.notifications.handleSmallIcon
import io.homeassistant.companion.android.common.notifications.handleText
import io.homeassistant.companion.android.common.notifications.parseColor
import io.homeassistant.companion.android.common.notifications.parseVibrationPattern
import io.homeassistant.companion.android.common.notifications.prepareText
import io.homeassistant.companion.android.common.sensors.BluetoothSensorManager import io.homeassistant.companion.android.common.sensors.BluetoothSensorManager
import io.homeassistant.companion.android.common.util.cancel import io.homeassistant.companion.android.common.util.cancel
import io.homeassistant.companion.android.common.util.cancelGroupIfNeeded import io.homeassistant.companion.android.common.util.cancelGroupIfNeeded
import io.homeassistant.companion.android.common.util.generalChannel
import io.homeassistant.companion.android.common.util.getActiveNotification import io.homeassistant.companion.android.common.util.getActiveNotification
import io.homeassistant.companion.android.database.notification.NotificationDao import io.homeassistant.companion.android.database.notification.NotificationDao
import io.homeassistant.companion.android.database.notification.NotificationItem import io.homeassistant.companion.android.database.notification.NotificationItem
@ -86,7 +89,6 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.net.URL import java.net.URL
import java.net.URLDecoder import java.net.URLDecoder
import java.util.Locale
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.R as commonR
@ -111,25 +113,17 @@ class MessagingManager @Inject constructor(
const val SETTINGS_PREFIX = "settings://" const val SETTINGS_PREFIX = "settings://"
const val NOTIFICATION_HISTORY = "notification_history" const val NOTIFICATION_HISTORY = "notification_history"
const val TITLE = "title"
const val MESSAGE = "message"
const val SUBJECT = "subject" const val SUBJECT = "subject"
const val IMPORTANCE = "importance"
const val TIMEOUT = "timeout" const val TIMEOUT = "timeout"
const val IMAGE_URL = "image" const val IMAGE_URL = "image"
const val ICON_URL = "icon_url" const val ICON_URL = "icon_url"
const val VIDEO_URL = "video" const val VIDEO_URL = "video"
const val VISIBILITY = "visibility" const val VISIBILITY = "visibility"
const val LED_COLOR = "ledColor"
const val VIBRATION_PATTERN = "vibrationPattern"
const val PERSISTENT = "persistent" const val PERSISTENT = "persistent"
const val CHRONOMETER = "chronometer" const val CHRONOMETER = "chronometer"
const val WHEN = "when" const val WHEN = "when"
const val GROUP_PREFIX = "group_"
const val KEY_TEXT_REPLY = "key_text_reply" const val KEY_TEXT_REPLY = "key_text_reply"
const val ALERT_ONCE = "alert_once"
const val INTENT_CLASS_NAME = "intent_class_name" const val INTENT_CLASS_NAME = "intent_class_name"
const val NOTIFICATION_ICON = "notification_icon"
const val URI = "URI" const val URI = "URI"
const val REPLY = "REPLY" const val REPLY = "REPLY"
const val BLE_ADVERTISE = "ble_advertise" const val BLE_ADVERTISE = "ble_advertise"
@ -138,7 +132,6 @@ class MessagingManager @Inject constructor(
const val PACKAGE_NAME = "package_name" const val PACKAGE_NAME = "package_name"
const val COMMAND = "command" const val COMMAND = "command"
const val TTS_TEXT = "tts_text" const val TTS_TEXT = "tts_text"
const val CHANNEL = "channel"
const val CONFIRMATION = "confirmation" const val CONFIRMATION = "confirmation"
// special intent constants // special intent constants
@ -186,16 +179,6 @@ class MessagingManager @Inject constructor(
const val RM_SILENT = "silent" const val RM_SILENT = "silent"
const val RM_VIBRATE = "vibrate" const val RM_VIBRATE = "vibrate"
// Channel streams
const val ALARM_STREAM = "alarm_stream"
const val ALARM_STREAM_MAX = "alarm_stream_max"
const val MUSIC_STREAM = "music_stream"
const val NOTIFICATION_STREAM = "notification_stream"
const val RING_STREAM = "ring_stream"
const val SYSTEM_STREAM = "system_stream"
const val CALL_STREAM = "call_stream"
const val DTMF_STREAM = "dtmf_stream"
// Enable/Disable Commands // Enable/Disable Commands
const val TURN_ON = "turn_on" const val TURN_ON = "turn_on"
const val TURN_OFF = "turn_off" const val TURN_OFF = "turn_off"
@ -266,8 +249,13 @@ class MessagingManager @Inject constructor(
val DND_COMMANDS = listOf(DND_ALARMS_ONLY, DND_ALL, DND_NONE, DND_PRIORITY_ONLY) val DND_COMMANDS = listOf(DND_ALARMS_ONLY, DND_ALL, DND_NONE, DND_PRIORITY_ONLY)
val RM_COMMANDS = listOf(RM_NORMAL, RM_SILENT, RM_VIBRATE) val RM_COMMANDS = listOf(RM_NORMAL, RM_SILENT, RM_VIBRATE)
val CHANNEL_VOLUME_STREAM = listOf( val CHANNEL_VOLUME_STREAM = listOf(
ALARM_STREAM, MUSIC_STREAM, NOTIFICATION_STREAM, RING_STREAM, CALL_STREAM, NotificationData.ALARM_STREAM,
SYSTEM_STREAM, DTMF_STREAM NotificationData.MUSIC_STREAM,
NotificationData.NOTIFICATION_STREAM,
NotificationData.RING_STREAM,
NotificationData.CALL_STREAM,
NotificationData.SYSTEM_STREAM,
NotificationData.DTMF_STREAM
) )
val ENABLE_COMMANDS = listOf(TURN_OFF, TURN_ON) val ENABLE_COMMANDS = listOf(TURN_OFF, TURN_ON)
val FORCE_COMMANDS = listOf(FORCE_OFF, FORCE_ON) val FORCE_COMMANDS = listOf(FORCE_OFF, FORCE_ON)
@ -315,7 +303,7 @@ class MessagingManager @Inject constructor(
} else { } else {
val jsonObject = JSONObject(jsonData) val jsonObject = JSONObject(jsonData)
val notificationRow = val notificationRow =
NotificationItem(0, now, jsonData[MESSAGE].toString(), jsonObject.toString(), source) NotificationItem(0, now, jsonData[NotificationData.MESSAGE].toString(), jsonObject.toString(), source)
notificationId = notificationDao.add(notificationRow) notificationId = notificationDao.add(notificationRow)
val confirmation = jsonData[CONFIRMATION]?.toBoolean() ?: false val confirmation = jsonData[CONFIRMATION]?.toBoolean() ?: false
@ -331,25 +319,25 @@ class MessagingManager @Inject constructor(
} }
when { when {
jsonData[MESSAGE] == REQUEST_LOCATION_UPDATE -> { jsonData[NotificationData.MESSAGE] == REQUEST_LOCATION_UPDATE -> {
Log.d(TAG, "Request location update") Log.d(TAG, "Request location update")
requestAccurateLocationUpdate() requestAccurateLocationUpdate()
} }
jsonData[MESSAGE] == CLEAR_NOTIFICATION && !jsonData["tag"].isNullOrBlank() -> { jsonData[NotificationData.MESSAGE] == CLEAR_NOTIFICATION && !jsonData["tag"].isNullOrBlank() -> {
Log.d(TAG, "Clearing notification with tag: ${jsonData["tag"]}") Log.d(TAG, "Clearing notification with tag: ${jsonData["tag"]}")
clearNotification(jsonData["tag"]!!) clearNotification(jsonData["tag"]!!)
} }
jsonData[MESSAGE] == REMOVE_CHANNEL && !jsonData[CHANNEL].isNullOrBlank() -> { jsonData[NotificationData.MESSAGE] == REMOVE_CHANNEL && !jsonData[NotificationData.CHANNEL].isNullOrBlank() -> {
Log.d(TAG, "Removing Notification channel ${jsonData[CHANNEL]}") Log.d(TAG, "Removing Notification channel ${jsonData[NotificationData.CHANNEL]}")
removeNotificationChannel(jsonData[CHANNEL]!!) removeNotificationChannel(jsonData[NotificationData.CHANNEL]!!)
} }
jsonData[MESSAGE] == TTS -> { jsonData[NotificationData.MESSAGE] == TTS -> {
Log.d(TAG, "Sending notification title to TTS") Log.d(TAG, "Sending notification title to TTS")
speakNotification(jsonData) speakNotification(jsonData)
} }
jsonData[MESSAGE] in DEVICE_COMMANDS -> { jsonData[NotificationData.MESSAGE] in DEVICE_COMMANDS -> {
Log.d(TAG, "Processing device command") Log.d(TAG, "Processing device command")
when (jsonData[MESSAGE]) { when (jsonData[NotificationData.MESSAGE]) {
COMMAND_DND -> { COMMAND_DND -> {
if (jsonData[COMMAND] in DND_COMMANDS) { if (jsonData[COMMAND] in DND_COMMANDS) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
@ -667,7 +655,7 @@ class MessagingManager @Inject constructor(
if (it == TextToSpeech.SUCCESS) { if (it == TextToSpeech.SUCCESS) {
val listener = object : UtteranceProgressListener() { val listener = object : UtteranceProgressListener() {
override fun onStart(p0: String?) { override fun onStart(p0: String?) {
if (data[MEDIA_STREAM] == ALARM_STREAM_MAX) if (data[MEDIA_STREAM] == NotificationData.ALARM_STREAM_MAX)
audioManager?.setStreamVolume( audioManager?.setStreamVolume(
AudioManager.STREAM_ALARM, AudioManager.STREAM_ALARM,
maxAlarmVolume!!, maxAlarmVolume!!,
@ -678,7 +666,7 @@ class MessagingManager @Inject constructor(
override fun onDone(p0: String?) { override fun onDone(p0: String?) {
textToSpeech?.stop() textToSpeech?.stop()
textToSpeech?.shutdown() textToSpeech?.shutdown()
if (data[MEDIA_STREAM] == ALARM_STREAM_MAX) if (data[MEDIA_STREAM] == NotificationData.ALARM_STREAM_MAX)
audioManager?.setStreamVolume( audioManager?.setStreamVolume(
AudioManager.STREAM_ALARM, AudioManager.STREAM_ALARM,
currentAlarmVolume!!, currentAlarmVolume!!,
@ -689,7 +677,7 @@ class MessagingManager @Inject constructor(
override fun onError(p0: String?) { override fun onError(p0: String?) {
textToSpeech?.stop() textToSpeech?.stop()
textToSpeech?.shutdown() textToSpeech?.shutdown()
if (data[MEDIA_STREAM] == ALARM_STREAM_MAX) if (data[MEDIA_STREAM] == NotificationData.ALARM_STREAM_MAX)
audioManager?.setStreamVolume( audioManager?.setStreamVolume(
AudioManager.STREAM_ALARM, AudioManager.STREAM_ALARM,
currentAlarmVolume!!, currentAlarmVolume!!,
@ -698,7 +686,7 @@ class MessagingManager @Inject constructor(
} }
override fun onStop(utteranceId: String?, interrupted: Boolean) { override fun onStop(utteranceId: String?, interrupted: Boolean) {
if (data[MEDIA_STREAM] == ALARM_STREAM_MAX) if (data[MEDIA_STREAM] == NotificationData.ALARM_STREAM_MAX)
audioManager?.setStreamVolume( audioManager?.setStreamVolume(
AudioManager.STREAM_ALARM, AudioManager.STREAM_ALARM,
currentAlarmVolume!!, currentAlarmVolume!!,
@ -707,7 +695,7 @@ class MessagingManager @Inject constructor(
} }
} }
textToSpeech?.setOnUtteranceProgressListener(listener) textToSpeech?.setOnUtteranceProgressListener(listener)
if (data[MEDIA_STREAM] == ALARM_STREAM || data[MEDIA_STREAM] == ALARM_STREAM_MAX) { if (data[MEDIA_STREAM] == NotificationData.ALARM_STREAM || data[MEDIA_STREAM] == NotificationData.ALARM_STREAM_MAX) {
val audioAttributes = AudioAttributes.Builder() val audioAttributes = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ALARM) .setUsage(AudioAttributes.USAGE_ALARM)
@ -729,7 +717,7 @@ class MessagingManager @Inject constructor(
} }
private fun handleDeviceCommands(data: Map<String, String>) { private fun handleDeviceCommands(data: Map<String, String>) {
val message = data[MESSAGE] val message = data[NotificationData.MESSAGE]
val command = data[COMMAND] val command = data[COMMAND]
when (message) { when (message) {
COMMAND_DND -> { COMMAND_DND -> {
@ -737,7 +725,7 @@ class MessagingManager @Inject constructor(
val notificationManager = val notificationManager =
context.getSystemService<NotificationManager>() context.getSystemService<NotificationManager>()
if (notificationManager?.isNotificationPolicyAccessGranted == false) { if (notificationManager?.isNotificationPolicyAccessGranted == false) {
notifyMissingPermission(data[MESSAGE].toString()) notifyMissingPermission(message.toString())
} else { } else {
when (command) { when (command) {
DND_ALARMS_ONLY -> notificationManager?.setInterruptionFilter( DND_ALARMS_ONLY -> notificationManager?.setInterruptionFilter(
@ -761,7 +749,7 @@ class MessagingManager @Inject constructor(
val notificationManager = val notificationManager =
context.getSystemService<NotificationManager>() context.getSystemService<NotificationManager>()
if (notificationManager?.isNotificationPolicyAccessGranted == false) { if (notificationManager?.isNotificationPolicyAccessGranted == false) {
notifyMissingPermission(data[MESSAGE].toString()) notifyMissingPermission(message.toString())
} else { } else {
processRingerMode(audioManager!!, command) processRingerMode(audioManager!!, command)
} }
@ -800,7 +788,7 @@ class MessagingManager @Inject constructor(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val notificationManager = context.getSystemService<NotificationManager>() val notificationManager = context.getSystemService<NotificationManager>()
if (notificationManager?.isNotificationPolicyAccessGranted == false) { if (notificationManager?.isNotificationPolicyAccessGranted == false) {
notifyMissingPermission(data[MESSAGE].toString()) notifyMissingPermission(message.toString())
} else { } else {
processStreamVolume( processStreamVolume(
audioManager!!, audioManager!!,
@ -825,7 +813,7 @@ class MessagingManager @Inject constructor(
} }
else -> { else -> {
Log.e(TAG, "Missing Bluetooth permissions, notifying user to grant permissions") Log.e(TAG, "Missing Bluetooth permissions, notifying user to grant permissions")
notifyMissingPermission(data[MESSAGE].toString()) notifyMissingPermission(message.toString())
} }
} }
} }
@ -910,7 +898,7 @@ class MessagingManager @Inject constructor(
COMMAND_ACTIVITY -> { COMMAND_ACTIVITY -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (!Settings.canDrawOverlays(context)) if (!Settings.canDrawOverlays(context))
notifyMissingPermission(data[MESSAGE].toString()) notifyMissingPermission(message.toString())
else if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED && data["tag"] == Intent.ACTION_CALL) { else if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED && data["tag"] == Intent.ACTION_CALL) {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
Toast.makeText( Toast.makeText(
@ -933,7 +921,7 @@ class MessagingManager @Inject constructor(
COMMAND_WEBVIEW -> { COMMAND_WEBVIEW -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (!Settings.canDrawOverlays(context)) if (!Settings.canDrawOverlays(context))
notifyMissingPermission(data[MESSAGE].toString()) notifyMissingPermission(message.toString())
else else
openWebview(command) openWebview(command)
} else } else
@ -963,7 +951,7 @@ class MessagingManager @Inject constructor(
if (!NotificationManagerCompat.getEnabledListenerPackages(context) if (!NotificationManagerCompat.getEnabledListenerPackages(context)
.contains(context.packageName) .contains(context.packageName)
) )
notifyMissingPermission(data[MESSAGE].toString()) notifyMissingPermission(message.toString())
else { else {
processMediaCommand(data) processMediaCommand(data)
} }
@ -972,7 +960,7 @@ class MessagingManager @Inject constructor(
COMMAND_LAUNCH_APP -> { COMMAND_LAUNCH_APP -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (!Settings.canDrawOverlays(context)) if (!Settings.canDrawOverlays(context))
notifyMissingPermission(data[MESSAGE].toString()) notifyMissingPermission(message.toString())
else else
launchApp(data) launchApp(data)
} else } else
@ -990,7 +978,7 @@ class MessagingManager @Inject constructor(
if (!processScreenCommands(data)) if (!processScreenCommands(data))
mainScope.launch { sendNotification(data) } mainScope.launch { sendNotification(data) }
} else } else
notifyMissingPermission(data[MESSAGE].toString()) notifyMissingPermission(message.toString())
} else if (!processScreenCommands(data)) } else if (!processScreenCommands(data))
mainScope.launch { sendNotification(data) } mainScope.launch { sendNotification(data) }
} }
@ -1063,24 +1051,24 @@ class MessagingManager @Inject constructor(
var previousGroup = "" var previousGroup = ""
var previousGroupId = 0 var previousGroupId = 0
if (!group.isNullOrBlank()) { if (!group.isNullOrBlank()) {
group = GROUP_PREFIX + group group = NotificationData.GROUP_PREFIX + group
groupId = group.hashCode() groupId = group.hashCode()
} else { } else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val notification = notificationManagerCompat.getActiveNotification(tag, messageId) val notification = notificationManagerCompat.getActiveNotification(tag, messageId)
if (notification != null && notification.isGroup) { if (notification != null && notification.isGroup) {
previousGroup = GROUP_PREFIX + notification.tag previousGroup = NotificationData.GROUP_PREFIX + notification.tag
previousGroupId = previousGroup.hashCode() previousGroupId = previousGroup.hashCode()
} }
} }
} }
val channelId = handleChannel(notificationManagerCompat, data) val channelId = handleChannel(context, notificationManagerCompat, data)
val notificationBuilder = NotificationCompat.Builder(context, channelId) val notificationBuilder = NotificationCompat.Builder(context, channelId)
handleSmallIcon(notificationBuilder, data) handleSmallIcon(context, notificationBuilder, data)
handleSound(notificationBuilder, data) handleSound(notificationBuilder, data)
@ -1088,11 +1076,11 @@ class MessagingManager @Inject constructor(
handleLargeIcon(notificationBuilder, data) handleLargeIcon(notificationBuilder, data)
handleGroup(notificationBuilder, group, data[ALERT_ONCE].toBoolean()) handleGroup(notificationBuilder, group, data[NotificationData.ALERT_ONCE].toBoolean())
handleTimeout(notificationBuilder, data) handleTimeout(notificationBuilder, data)
handleColor(notificationBuilder, data) handleColor(context, notificationBuilder, data)
handleSticky(notificationBuilder, data) handleSticky(notificationBuilder, data)
@ -1127,7 +1115,7 @@ class MessagingManager @Inject constructor(
notify(tag, messageId, notificationBuilder.build()) notify(tag, messageId, notificationBuilder.build())
if (!group.isNullOrBlank()) { if (!group.isNullOrBlank()) {
Log.d(TAG, "Show group notification with tag \"$group\" and id \"$groupId\"") Log.d(TAG, "Show group notification with tag \"$group\" and id \"$groupId\"")
notify(group, groupId, getGroupNotificationBuilder(channelId, group, data).build()) notify(group, groupId, getGroupNotificationBuilder(context, channelId, group, data).build())
} else { } else {
if (!previousGroup.isBlank()) { if (!previousGroup.isBlank()) {
Log.d( Log.d(
@ -1163,22 +1151,6 @@ class MessagingManager @Inject constructor(
} }
} }
private fun handleSmallIcon(
builder: NotificationCompat.Builder,
data: Map<String, String>
) {
if (data[NOTIFICATION_ICON]?.startsWith("mdi:") == true && !data[NOTIFICATION_ICON]?.substringAfter("mdi:").isNullOrBlank() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val iconName = data[NOTIFICATION_ICON]!!.split(":")[1]
val iconDrawable =
IconicsDrawable(context, "cmd-$iconName")
if (iconDrawable.icon != null)
builder.setSmallIcon(iconDrawable.toAndroidIconCompat())
else
builder.setSmallIcon(commonR.drawable.ic_stat_ic_notification)
} else
builder.setSmallIcon(commonR.drawable.ic_stat_ic_notification)
}
private fun handleContentIntent( private fun handleContentIntent(
builder: NotificationCompat.Builder, builder: NotificationCompat.Builder,
messageId: Int, messageId: Int,
@ -1226,34 +1198,11 @@ class MessagingManager @Inject constructor(
} }
} }
private fun getGroupNotificationBuilder(
channelId: String,
group: String,
data: Map<String, String>
): NotificationCompat.Builder {
val groupNotificationBuilder = NotificationCompat.Builder(context, channelId)
.setStyle(
NotificationCompat.BigTextStyle()
.setSummaryText(
prepareText(group.substring(GROUP_PREFIX.length))
)
)
.setGroup(group)
.setGroupSummary(true)
if (data[ALERT_ONCE].toBoolean())
groupNotificationBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
handleColor(groupNotificationBuilder, data)
handleSmallIcon(groupNotificationBuilder, data)
return groupNotificationBuilder
}
private fun handleSound( private fun handleSound(
builder: NotificationCompat.Builder, builder: NotificationCompat.Builder,
data: Map<String, String> data: Map<String, String>
) { ) {
if (data[CHANNEL] == ALARM_STREAM) { if (data[NotificationData.CHANNEL] == NotificationData.ALARM_STREAM) {
builder.setCategory(Notification.CATEGORY_ALARM) builder.setCategory(Notification.CATEGORY_ALARM)
builder.setSound( builder.setSound(
RingtoneManager.getActualDefaultRingtoneUri( RingtoneManager.getActualDefaultRingtoneUri(
@ -1269,38 +1218,17 @@ class MessagingManager @Inject constructor(
} else { } else {
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
} }
if (data[ALERT_ONCE].toBoolean()) if (data[NotificationData.ALERT_ONCE].toBoolean())
builder.setOnlyAlertOnce(true) builder.setOnlyAlertOnce(true)
} }
private fun handleColor(
builder: NotificationCompat.Builder,
data: Map<String, String>
) {
val colorString = data["color"]
val color = parseColor(colorString, commonR.color.colorPrimary)
builder.color = color
}
private fun parseColor(colorString: String?, default: Int): Int {
if (!colorString.isNullOrBlank()) {
try {
return Color.parseColor(colorString)
} catch (e: Exception) {
Log.e(TAG, "Unable to parse color", e)
}
}
return ContextCompat.getColor(context, default)
}
private fun handleLegacyLedColor( private fun handleLegacyLedColor(
builder: NotificationCompat.Builder, builder: NotificationCompat.Builder,
data: Map<String, String> data: Map<String, String>
) { ) {
val ledColor = data[LED_COLOR] val ledColor = data[NotificationData.LED_COLOR]
if (!ledColor.isNullOrBlank()) { if (!ledColor.isNullOrBlank()) {
builder.setLights(parseColor(ledColor, commonR.color.colorPrimary), 3000, 3000) builder.setLights(parseColor(context, ledColor, commonR.color.colorPrimary), 3000, 3000)
} }
} }
@ -1308,7 +1236,7 @@ class MessagingManager @Inject constructor(
builder: NotificationCompat.Builder, builder: NotificationCompat.Builder,
data: Map<String, String> data: Map<String, String>
) { ) {
val vibrationPattern = data[VIBRATION_PATTERN] val vibrationPattern = data[NotificationData.VIBRATION_PATTERN]
if (!vibrationPattern.isNullOrBlank()) { if (!vibrationPattern.isNullOrBlank()) {
val arrVibrationPattern = parseVibrationPattern(vibrationPattern) val arrVibrationPattern = parseVibrationPattern(vibrationPattern)
if (arrVibrationPattern.isNotEmpty()) { if (arrVibrationPattern.isNotEmpty()) {
@ -1323,7 +1251,7 @@ class MessagingManager @Inject constructor(
) { ) {
// Use importance property for legacy priority support // Use importance property for legacy priority support
val priority = data[IMPORTANCE] val priority = data[NotificationData.IMPORTANCE]
when (priority) { when (priority) {
"high" -> { "high" -> {
@ -1344,32 +1272,6 @@ class MessagingManager @Inject constructor(
} }
} }
@RequiresApi(Build.VERSION_CODES.N)
private fun handleImportance(
data: Map<String, String>
): Int {
val importance = data[IMPORTANCE]
when (importance) {
"high" -> {
return NotificationManager.IMPORTANCE_HIGH
}
"low" -> {
return NotificationManager.IMPORTANCE_LOW
}
"max" -> {
return NotificationManager.IMPORTANCE_MAX
}
"min" -> {
return NotificationManager.IMPORTANCE_MIN
}
else -> {
return NotificationManager.IMPORTANCE_DEFAULT
}
}
}
private fun handleTimeout( private fun handleTimeout(
builder: NotificationCompat.Builder, builder: NotificationCompat.Builder,
data: Map<String, String> data: Map<String, String>
@ -1407,29 +1309,6 @@ class MessagingManager @Inject constructor(
} }
} }
private fun handleText(
builder: NotificationCompat.Builder,
data: Map<String, String>
) {
data[TITLE]?.let {
builder.setContentTitle(prepareText(it))
}
data[MESSAGE]?.let {
val text = prepareText(it)
builder.setContentText(text)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(text))
}
}
private fun prepareText(
text: String
): Spanned {
// Replace control char \r\n, \r, \n and also \r\n, \r, \n as text literals in strings to <br>
var brText = text.replace("(\r\n|\r|\n)|(\\\\r\\\\n|\\\\r|\\\\n)".toRegex(), "<br>")
var emojiParsedText = EmojiParser.parseToUnicode(brText)
return HtmlCompat.fromHtml(emojiParsedText, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
private suspend fun handleLargeIcon( private suspend fun handleLargeIcon(
builder: NotificationCompat.Builder, builder: NotificationCompat.Builder,
data: Map<String, String> data: Map<String, String>
@ -1509,11 +1388,11 @@ class MessagingManager @Inject constructor(
) )
} }
data[TITLE]?.let { rawTitle -> data[NotificationData.TITLE]?.let { rawTitle ->
remoteViewFlipper.setTextViewText(R.id.title, rawTitle) remoteViewFlipper.setTextViewText(R.id.title, rawTitle)
} }
data[MESSAGE]?.let { rawMessage -> data[NotificationData.MESSAGE]?.let { rawMessage ->
remoteViewFlipper.setTextViewText(R.id.info, rawMessage) remoteViewFlipper.setTextViewText(R.id.info, rawMessage)
} }
@ -1777,114 +1656,6 @@ class MessagingManager @Inject constructor(
} }
} }
private fun handleChannel(
notificationManagerCompat: NotificationManagerCompat,
data: Map<String, String>
): String {
// Define some values for a default channel
var channelID = generalChannel
var channelName = "General"
if (!data[CHANNEL].isNullOrEmpty()) {
channelID = createChannelID(data[CHANNEL].toString())
channelName = data[CHANNEL].toString().trim()
}
// Since android Oreo notification channel is needed.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelID,
channelName,
handleImportance(data)
)
if (channelName == ALARM_STREAM)
handleChannelSound(channel)
setChannelLedColor(data, channel)
setChannelVibrationPattern(data, channel)
notificationManagerCompat.createNotificationChannel(channel)
}
return channelID
}
private fun setChannelLedColor(
data: Map<String, String>,
channel: NotificationChannel
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ledColor = data[LED_COLOR]
if (!ledColor.isNullOrBlank()) {
channel.enableLights(true)
channel.lightColor = parseColor(ledColor, commonR.color.colorPrimary)
}
}
}
private fun setChannelVibrationPattern(
data: Map<String, String>,
channel: NotificationChannel
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val vibrationPattern = data[VIBRATION_PATTERN]
val arrVibrationPattern = parseVibrationPattern(vibrationPattern)
if (arrVibrationPattern.isNotEmpty()) {
channel.vibrationPattern = arrVibrationPattern
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun handleChannelSound(
channel: NotificationChannel
) {
val audioAttributes = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED)
.setLegacyStreamType(AudioManager.STREAM_ALARM)
.setUsage(AudioAttributes.USAGE_ALARM)
.build()
channel.setSound(
RingtoneManager.getActualDefaultRingtoneUri(
context,
RingtoneManager.TYPE_ALARM
)
?: RingtoneManager.getActualDefaultRingtoneUri(
context,
RingtoneManager.TYPE_RINGTONE
),
audioAttributes
)
}
private fun parseVibrationPattern(
vibrationPattern: String?
): LongArray {
if (!vibrationPattern.isNullOrBlank()) {
val pattern = vibrationPattern.split(",").toTypedArray()
val list = mutableListOf<Long>()
pattern.forEach { it ->
val ms = it.trim().toLongOrNull()
if (ms != null) {
list.add(ms)
}
}
if (list.count() > 0) {
return list.toLongArray()
}
}
return LongArray(0)
}
private fun createChannelID(
channelName: String
): String {
return channelName
.trim()
.toLowerCase(Locale.ROOT)
.replace(" ", "_")
}
@RequiresApi(Build.VERSION_CODES.M) @RequiresApi(Build.VERSION_CODES.M)
private fun requestDNDPermission() { private fun requestDNDPermission() {
val intent = val intent =
@ -2002,13 +1773,13 @@ class MessagingManager @Inject constructor(
private fun processStreamVolume(audioManager: AudioManager, stream: String, volume: Int) { private fun processStreamVolume(audioManager: AudioManager, stream: String, volume: Int) {
when (stream) { when (stream) {
ALARM_STREAM -> adjustVolumeStream(AudioManager.STREAM_ALARM, volume, audioManager) NotificationData.ALARM_STREAM -> adjustVolumeStream(AudioManager.STREAM_ALARM, volume, audioManager)
MUSIC_STREAM -> adjustVolumeStream(AudioManager.STREAM_MUSIC, volume, audioManager) NotificationData.MUSIC_STREAM -> adjustVolumeStream(AudioManager.STREAM_MUSIC, volume, audioManager)
NOTIFICATION_STREAM -> adjustVolumeStream(AudioManager.STREAM_NOTIFICATION, volume, audioManager) NotificationData.NOTIFICATION_STREAM -> adjustVolumeStream(AudioManager.STREAM_NOTIFICATION, volume, audioManager)
RING_STREAM -> adjustVolumeStream(AudioManager.STREAM_RING, volume, audioManager) NotificationData.RING_STREAM -> adjustVolumeStream(AudioManager.STREAM_RING, volume, audioManager)
CALL_STREAM -> adjustVolumeStream(AudioManager.STREAM_VOICE_CALL, volume, audioManager) NotificationData.CALL_STREAM -> adjustVolumeStream(AudioManager.STREAM_VOICE_CALL, volume, audioManager)
SYSTEM_STREAM -> adjustVolumeStream(AudioManager.STREAM_SYSTEM, volume, audioManager) NotificationData.SYSTEM_STREAM -> adjustVolumeStream(AudioManager.STREAM_SYSTEM, volume, audioManager)
DTMF_STREAM -> adjustVolumeStream(AudioManager.STREAM_DTMF, volume, audioManager) NotificationData.DTMF_STREAM -> adjustVolumeStream(AudioManager.STREAM_DTMF, volume, audioManager)
else -> Log.d(TAG, "Skipping command due to invalid channel stream") else -> Log.d(TAG, "Skipping command due to invalid channel stream")
} }
} }
@ -2160,12 +1931,12 @@ class MessagingManager @Inject constructor(
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
val success = Settings.System.putInt( val success = Settings.System.putInt(
contentResolver, contentResolver,
when (data[MESSAGE].toString()) { when (data[NotificationData.MESSAGE].toString()) {
COMMAND_SCREEN_BRIGHTNESS_LEVEL -> Settings.System.SCREEN_BRIGHTNESS COMMAND_SCREEN_BRIGHTNESS_LEVEL -> Settings.System.SCREEN_BRIGHTNESS
COMMAND_AUTO_SCREEN_BRIGHTNESS -> Settings.System.SCREEN_BRIGHTNESS_MODE COMMAND_AUTO_SCREEN_BRIGHTNESS -> Settings.System.SCREEN_BRIGHTNESS_MODE
else -> Settings.System.SCREEN_OFF_TIMEOUT else -> Settings.System.SCREEN_OFF_TIMEOUT
}, },
when (data[MESSAGE].toString()) { when (data[NotificationData.MESSAGE].toString()) {
COMMAND_SCREEN_BRIGHTNESS_LEVEL -> command!!.toInt().coerceIn(0, 255) COMMAND_SCREEN_BRIGHTNESS_LEVEL -> command!!.toInt().coerceIn(0, 255)
COMMAND_AUTO_SCREEN_BRIGHTNESS -> { COMMAND_AUTO_SCREEN_BRIGHTNESS -> {
if (command == TURN_ON) if (command == TURN_ON)
@ -2188,7 +1959,7 @@ class MessagingManager @Inject constructor(
if (context.applicationInfo.processName == item.processName) { if (context.applicationInfo.processName == item.processName) {
if (item.importance != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { if (item.importance != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
val data = val data =
mutableMapOf(MESSAGE to context.getString(commonR.string.missing_command_permission)) mutableMapOf(NotificationData.MESSAGE to context.getString(commonR.string.missing_command_permission))
runBlocking { runBlocking {
sendNotification(data) sendNotification(data)
} }

View file

@ -77,4 +77,8 @@ dependencies {
implementation("com.mikepenz:iconics-core:5.4.0") implementation("com.mikepenz:iconics-core:5.4.0")
implementation("com.mikepenz:community-material-typeface:7.0.96.0-kotlin@aar") implementation("com.mikepenz:community-material-typeface:7.0.96.0-kotlin@aar")
implementation("com.vdurmont:emoji-java:5.1.1") {
exclude(group = "org.json", module = "json")
}
} }

View file

@ -3,5 +3,6 @@ package io.homeassistant.companion.android.common.data.integration
data class DeviceRegistration( data class DeviceRegistration(
val appVersion: String? = null, val appVersion: String? = null,
val deviceName: String? = null, val deviceName: String? = null,
var pushToken: String? = null var pushToken: String? = null,
var pushWebsocket: Boolean = true
) )

View file

@ -759,7 +759,7 @@ class IntegrationRepositoryImpl @Inject constructor(
val oldDeviceRegistration = getRegistration() val oldDeviceRegistration = getRegistration()
val pushToken = deviceRegistration.pushToken ?: oldDeviceRegistration.pushToken val pushToken = deviceRegistration.pushToken ?: oldDeviceRegistration.pushToken
val appData = mutableMapOf<String, Any>("push_websocket_channel" to true) val appData = mutableMapOf<String, Any>("push_websocket_channel" to deviceRegistration.pushWebsocket)
if (!pushToken.isNullOrBlank()) { if (!pushToken.isNullOrBlank()) {
appData["push_url"] = PUSH_URL appData["push_url"] = PUSH_URL
appData["push_token"] = pushToken appData["push_token"] = pushToken

View file

@ -0,0 +1,271 @@
package io.homeassistant.companion.android.common.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.graphics.Color
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.RingtoneManager
import android.os.Build
import android.text.Spanned
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.utils.toAndroidIconCompat
import com.vdurmont.emoji.EmojiParser
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.common.util.generalChannel
import java.util.Locale
object NotificationData {
const val TAG = "MessagingService"
const val TITLE = "title"
const val MESSAGE = "message"
const val GROUP_PREFIX = "group_"
const val CHANNEL = "channel"
const val IMPORTANCE = "importance"
const val LED_COLOR = "ledColor"
const val VIBRATION_PATTERN = "vibrationPattern"
const val NOTIFICATION_ICON = "notification_icon"
const val ALERT_ONCE = "alert_once"
// Channel streams
const val ALARM_STREAM = "alarm_stream"
const val ALARM_STREAM_MAX = "alarm_stream_max"
const val MUSIC_STREAM = "music_stream"
const val NOTIFICATION_STREAM = "notification_stream"
const val RING_STREAM = "ring_stream"
const val SYSTEM_STREAM = "system_stream"
const val CALL_STREAM = "call_stream"
const val DTMF_STREAM = "dtmf_stream"
}
fun createChannelID(
channelName: String
): String {
return channelName
.trim()
.lowercase(Locale.ROOT)
.replace(" ", "_")
}
fun handleChannel(
context: Context,
notificationManagerCompat: NotificationManagerCompat,
data: Map<String, String>
): String {
// Define some values for a default channel
var channelID = generalChannel
var channelName = "General"
if (!data[NotificationData.CHANNEL].isNullOrEmpty()) {
channelID = createChannelID(data[NotificationData.CHANNEL].toString())
channelName = data[NotificationData.CHANNEL].toString().trim()
}
// Since android Oreo notification channel is needed.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelID,
channelName,
handleImportance(data)
)
if (channelName == NotificationData.ALARM_STREAM)
handleChannelSound(context, channel)
setChannelLedColor(context, data, channel)
setChannelVibrationPattern(data, channel)
notificationManagerCompat.createNotificationChannel(channel)
}
return channelID
}
@RequiresApi(Build.VERSION_CODES.N)
fun handleImportance(
data: Map<String, String>
): Int {
when (data[NotificationData.IMPORTANCE]) {
"high" -> {
return NotificationManager.IMPORTANCE_HIGH
}
"low" -> {
return NotificationManager.IMPORTANCE_LOW
}
"max" -> {
return NotificationManager.IMPORTANCE_MAX
}
"min" -> {
return NotificationManager.IMPORTANCE_MIN
}
else -> {
return NotificationManager.IMPORTANCE_DEFAULT
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun handleChannelSound(
context: Context,
channel: NotificationChannel
) {
val audioAttributes = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED)
.setLegacyStreamType(AudioManager.STREAM_ALARM)
.setUsage(AudioAttributes.USAGE_ALARM)
.build()
channel.setSound(
RingtoneManager.getActualDefaultRingtoneUri(
context,
RingtoneManager.TYPE_ALARM
)
?: RingtoneManager.getActualDefaultRingtoneUri(
context,
RingtoneManager.TYPE_RINGTONE
),
audioAttributes
)
}
fun setChannelLedColor(
context: Context,
data: Map<String, String>,
channel: NotificationChannel
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ledColor = data[NotificationData.LED_COLOR]
if (!ledColor.isNullOrBlank()) {
channel.enableLights(true)
channel.lightColor = parseColor(context, ledColor, R.color.colorPrimary)
}
}
}
fun setChannelVibrationPattern(
data: Map<String, String>,
channel: NotificationChannel
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val vibrationPattern = data[NotificationData.VIBRATION_PATTERN]
val arrVibrationPattern = parseVibrationPattern(vibrationPattern)
if (arrVibrationPattern.isNotEmpty()) {
channel.vibrationPattern = arrVibrationPattern
}
}
}
fun parseVibrationPattern(
vibrationPattern: String?
): LongArray {
if (!vibrationPattern.isNullOrBlank()) {
val pattern = vibrationPattern.split(",").toTypedArray()
val list = mutableListOf<Long>()
pattern.forEach {
val ms = it.trim().toLongOrNull()
if (ms != null) {
list.add(ms)
}
}
if (list.isNotEmpty()) {
return list.toLongArray()
}
}
return LongArray(0)
}
fun parseColor(
context: Context,
colorString: String?,
default: Int
): Int {
if (!colorString.isNullOrBlank()) {
try {
return Color.parseColor(colorString)
} catch (e: Exception) {
Log.e(NotificationData.TAG, "Unable to parse color", e)
}
}
return ContextCompat.getColor(context, default)
}
fun handleSmallIcon(
context: Context,
builder: NotificationCompat.Builder,
data: Map<String, String>
) {
val notificationIcon = data[NotificationData.NOTIFICATION_ICON] ?: ""
if (notificationIcon.startsWith("mdi:") && notificationIcon.substringAfter("mdi:").isNotBlank() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val iconName = notificationIcon.split(":")[1]
val iconDrawable =
IconicsDrawable(context, "cmd-$iconName")
if (iconDrawable.icon != null)
builder.setSmallIcon(iconDrawable.toAndroidIconCompat())
else
builder.setSmallIcon(R.drawable.ic_stat_ic_notification)
} else
builder.setSmallIcon(R.drawable.ic_stat_ic_notification)
}
fun getGroupNotificationBuilder(
context: Context,
channelId: String,
group: String,
data: Map<String, String>
): NotificationCompat.Builder {
val groupNotificationBuilder = NotificationCompat.Builder(context, channelId)
.setStyle(
NotificationCompat.BigTextStyle()
.setSummaryText(
prepareText(group.substring(NotificationData.GROUP_PREFIX.length))
)
)
.setGroup(group)
.setGroupSummary(true)
if (data[NotificationData.ALERT_ONCE].toBoolean())
groupNotificationBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
handleColor(context, groupNotificationBuilder, data)
handleSmallIcon(context, groupNotificationBuilder, data)
return groupNotificationBuilder
}
fun prepareText(
text: String
): Spanned {
// Replace control char \r\n, \r, \n and also \r\n, \r, \n as text literals in strings to <br>
val brText = text.replace("(\r\n|\r|\n)|(\\\\r\\\\n|\\\\r|\\\\n)".toRegex(), "<br>")
val emojiParsedText = EmojiParser.parseToUnicode(brText)
return HtmlCompat.fromHtml(emojiParsedText, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
fun handleColor(
context: Context,
builder: NotificationCompat.Builder,
data: Map<String, String>
) {
val colorString = data["color"]
val color = parseColor(context, colorString, R.color.colorPrimary)
builder.color = color
}
fun handleText(
builder: NotificationCompat.Builder,
data: Map<String, String>
) {
data[NotificationData.TITLE]?.let {
builder.setContentTitle(prepareText(it))
}
data[NotificationData.MESSAGE]?.let {
val text = prepareText(it)
builder.setContentText(text)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(text))
}
}

View file

@ -5,6 +5,7 @@ plugins {
id("com.github.triplet.play") id("com.github.triplet.play")
kotlin("kapt") kotlin("kapt")
id("dagger.hilt.android.plugin") id("dagger.hilt.android.plugin")
id("com.google.gms.google-services")
} }
android { android {
@ -127,4 +128,7 @@ dependencies {
implementation("androidx.wear.watchface:watchface-complications-data-source-ktx:1.1.1") implementation("androidx.wear.watchface:watchface-complications-data-source-ktx:1.1.1")
implementation("androidx.health:health-services-client:1.0.0-beta02") implementation("androidx.health:health-services-client:1.0.0-beta02")
implementation(platform("com.google.firebase:firebase-bom:31.1.1"))
implementation("com.google.firebase:firebase-messaging")
} }

View file

@ -170,6 +170,14 @@
android:path="/updateTemplateTile" /> android:path="/updateTemplateTile" />
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".notifications.FirebaseCloudMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>

View file

@ -16,6 +16,7 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.De
import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryResponse import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryUpdatedEvent import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryUpdatedEvent
import io.homeassistant.companion.android.data.SimplifiedEntity import io.homeassistant.companion.android.data.SimplifiedEntity
import io.homeassistant.companion.android.onboarding.getMessagingToken
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -179,7 +180,8 @@ class HomePresenterImpl @Inject constructor(
DeviceRegistration( DeviceRegistration(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
null, null,
null getMessagingToken(),
false
) )
) )
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -0,0 +1,66 @@
package io.homeassistant.companion.android.notifications
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.authentication.SessionState
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class FirebaseCloudMessagingService : FirebaseMessagingService() {
companion object {
private const val TAG = "FCMService"
private const val SOURCE = "FCM"
}
@Inject
lateinit var integrationUseCase: IntegrationRepository
@Inject
lateinit var authenticationUseCase: AuthenticationRepository
@Inject
lateinit var messagingManager: MessagingManager
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onMessageReceived(remoteMessage: RemoteMessage) {
Log.d(TAG, "From: ${remoteMessage.from} and data: ${remoteMessage.data}")
messagingManager.handleMessage(remoteMessage.data, SOURCE)
}
/**
* Called if InstanceID token is updated. This may occur if the security of
* the previous token had been compromised. Note that this is called when the InstanceID token
* is initially generated so this is where you would retrieve the token.
*/
override fun onNewToken(token: String) {
mainScope.launch {
Log.d(TAG, "Refreshed token: $token")
if (authenticationUseCase.getSessionState() == SessionState.ANONYMOUS) {
Log.d(TAG, "Not trying to update registration since we aren't authenticated.")
return@launch
}
try {
integrationUseCase.updateRegistration(
DeviceRegistration(
pushToken = token,
pushWebsocket = false
)
)
} catch (e: Exception) {
// TODO: Store for update later
Log.e(TAG, "Issue updating token", e)
}
}
}
}

View file

@ -0,0 +1,89 @@
package io.homeassistant.companion.android.notifications
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import io.homeassistant.companion.android.common.notifications.NotificationData
import io.homeassistant.companion.android.common.notifications.getGroupNotificationBuilder
import io.homeassistant.companion.android.common.notifications.handleChannel
import io.homeassistant.companion.android.common.notifications.handleSmallIcon
import io.homeassistant.companion.android.common.notifications.handleText
import io.homeassistant.companion.android.common.util.cancelGroupIfNeeded
import io.homeassistant.companion.android.common.util.getActiveNotification
import io.homeassistant.companion.android.database.AppDatabase
import io.homeassistant.companion.android.database.notification.NotificationItem
import org.json.JSONObject
import javax.inject.Inject
class MessagingManager @Inject constructor(
@ApplicationContext val context: Context,
) {
companion object {
const val TAG = "MessagingManager"
}
fun handleMessage(notificationData: Map<String, String>, source: String) {
val notificationDao = AppDatabase.getInstance(context).notificationDao()
val now = System.currentTimeMillis()
val jsonObject = (notificationData as Map<*, *>?)?.let { JSONObject(it) }
val notificationRow =
NotificationItem(0, now, notificationData[NotificationData.MESSAGE].toString(), jsonObject.toString(), source)
notificationDao.add(notificationRow)
sendNotification(notificationData, now)
}
@SuppressLint("MissingPermission")
private fun sendNotification(data: Map<String, String>, received: Long? = null) {
val notificationManagerCompat = NotificationManagerCompat.from(context)
val tag = data["tag"]
val messageId = tag?.hashCode() ?: received?.toInt() ?: System.currentTimeMillis().toInt()
var group = data["group"]
var groupId = 0
var previousGroup = ""
var previousGroupId = 0
if (!group.isNullOrBlank()) {
group = NotificationData.GROUP_PREFIX + group
groupId = group.hashCode()
} else {
val notification = notificationManagerCompat.getActiveNotification(tag, messageId)
if (notification != null && notification.isGroup) {
previousGroup = NotificationData.GROUP_PREFIX + notification.tag
previousGroupId = previousGroup.hashCode()
}
}
val channelId = handleChannel(context, notificationManagerCompat, data)
val notificationBuilder = NotificationCompat.Builder(context, channelId)
handleSmallIcon(context, notificationBuilder, data)
handleText(notificationBuilder, data)
notificationManagerCompat.apply {
Log.d(TAG, "Show notification with tag \"$tag\" and id \"$messageId\"")
notify(tag, messageId, notificationBuilder.build())
if (!group.isNullOrBlank()) {
Log.d(TAG, "Show group notification with tag \"$group\" and id \"$groupId\"")
notify(group, groupId, getGroupNotificationBuilder(context, channelId, group, data).build())
} else {
if (previousGroup.isNotBlank()) {
Log.d(
TAG,
"Remove group notification with tag \"$previousGroup\" and id \"$previousGroupId\""
)
notificationManagerCompat.cancelGroupIfNeeded(previousGroup, previousGroupId)
}
}
}
}
}

View file

@ -0,0 +1,14 @@
package io.homeassistant.companion.android.onboarding
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.tasks.await
suspend fun getMessagingToken(): String {
return try {
FirebaseMessaging.getInstance().token.await()
} catch (e: Exception) {
Log.e("MessagingToken", "Issue getting token", e)
""
}
}

View file

@ -6,6 +6,7 @@ import dagger.hilt.android.qualifiers.ActivityContext
import io.homeassistant.companion.android.BuildConfig import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.onboarding.getMessagingToken
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -28,7 +29,9 @@ class MobileAppIntegrationPresenterImpl @Inject constructor(
private suspend fun createRegistration(deviceName: String): DeviceRegistration { private suspend fun createRegistration(deviceName: String): DeviceRegistration {
return DeviceRegistration( return DeviceRegistration(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
deviceName deviceName,
getMessagingToken(),
false
) )
} }

View file

@ -24,6 +24,7 @@ import io.homeassistant.companion.android.database.wear.getAll
import io.homeassistant.companion.android.database.wear.replaceAll import io.homeassistant.companion.android.database.wear.replaceAll
import io.homeassistant.companion.android.home.HomeActivity import io.homeassistant.companion.android.home.HomeActivity
import io.homeassistant.companion.android.home.HomePresenterImpl import io.homeassistant.companion.android.home.HomePresenterImpl
import io.homeassistant.companion.android.onboarding.getMessagingToken
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -124,7 +125,9 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange
integrationUseCase.registerDevice( integrationUseCase.registerDevice(
DeviceRegistration( DeviceRegistration(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
deviceName deviceName,
getMessagingToken(),
false
) )
) )