From 81550e36006961f67ed54f92724dcb0a40d71f8a Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Fri, 24 May 2024 11:04:24 +0200 Subject: [PATCH] #200: Added push notifications if observation cannot be started --- .../HR/PolarHeartRateObservation.kt | 19 ++++++++- .../services/LocalPushNotificationService.kt | 40 +++++++++++------- .../src/main/res/values-de/error-strings.xml | 3 ++ .../src/main/res/values/error-strings.xml | 3 ++ .../PolarVerityHeartRateObservation.swift | 4 ++ .../Resources/Strings/de.lproj/Errors.strings | 3 ++ .../Resources/Strings/en.lproj/Errors.strings | 3 ++ .../LocalPushNotificationService.swift | 42 ++++++++++--------- .../more/more_app_mutliplatform/Shared.kt | 1 - .../database/schemas/NotificationSchema.kt | 10 +++++ .../observations/Observation.kt | 39 +++++++++++++++-- .../notification/NotificationManager.kt | 1 + .../BluetoothController.kt | 10 ++++- 13 files changed, 137 insertions(+), 41 deletions(-) diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt index a1b7c35c..17f8b941 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt @@ -18,6 +18,8 @@ import androidx.core.app.ActivityCompat import io.github.aakira.napier.Napier import io.reactivex.rxjava3.disposables.Disposable import io.redlink.more.app.android.MoreApplication +import io.redlink.more.app.android.R +import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.app.android.observations.pauseObservation import io.redlink.more.app.android.observations.showPermissionAlertDialog import io.redlink.more.app.android.services.sensorsListener.BluetoothStateListener @@ -71,20 +73,35 @@ class PolarHeartRateObservation : message = "HR Recording error: ${error.stackTraceToString()}" ) pauseObservation(PolarVerityHeartRateType(emptySet())) - updateObservationErrors() + showObservationErrorNotification( + stringResource(R.string.observation_bluetooth_error), + stringResource(R.string.observation_error) + ) }) deviceConnectionListener = listenToDeviceConnection() true } catch (exception: Exception) { Napier.e(tag = "PolarHeartRateObservation::start") { exception.stackTraceToString() } + showObservationErrorNotification( + stringResource(R.string.observation_cannot_start), + stringResource(R.string.observation_error) + ) false } } ?: run { Napier.d(tag = "PolarHeartRateObservation::start") { "No connected devices..." } + showObservationErrorNotification( + stringResource(R.string.observation_cannot_start), + stringResource(R.string.observation_error) + ) false } } Napier.d(tag = "PolarHeartRateObservation::start") { "No connected devices..." } + showObservationErrorNotification( + stringResource(R.string.observation_cannot_start), + stringResource(R.string.observation_error) + ) return false } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/services/LocalPushNotificationService.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/LocalPushNotificationService.kt index 5d3b2627..6dee95e5 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/services/LocalPushNotificationService.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/services/LocalPushNotificationService.kt @@ -32,13 +32,11 @@ class LocalPushNotificationService(private val context: Context) : LocalNotifica override fun displayNotification(notification: NotificationSchema) { notification.title?.let { title -> notification.notificationBody?.let { message -> - val intent = Intent(context, ContentActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - intent.action = NotificationBroadcastReceiver.NOTIFICATION_SET_ON_READ_ACTION - intent.putExtra(MSG_ID, notification.notificationId) - - notification.deepLink()?.let { - intent.data = Uri.parse(it) + val intent = Intent(context, ContentActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + action = NotificationBroadcastReceiver.NOTIFICATION_SET_ON_READ_ACTION + putExtra(MSG_ID, notification.notificationId) + notification.deepLink()?.let { data = Uri.parse(it) } } val pendingIntent = PendingIntent.getActivity( @@ -46,7 +44,7 @@ class LocalPushNotificationService(private val context: Context) : LocalNotifica PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE ) - val channelId: String = + val channelId = notification.channelId ?: context.getString(R.string.default_channel_id) val notificationBuilder = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.mipmap.ic_more_logo_hf_v2_round) @@ -56,22 +54,36 @@ class LocalPushNotificationService(private val context: Context) : LocalNotifica .setSound(Settings.System.DEFAULT_NOTIFICATION_URI) .setContentIntent(pendingIntent) - context.getSystemService(NotificationManager::class.java) - ?.let { notificationManager -> + val notificationManager = context.getSystemService(NotificationManager::class.java) + if (notificationManager != null) { + val channel = notificationManager.getNotificationChannel(channelId) + if (channel == null) { val name = context.getString(R.string.notification_channel_name) val descriptionText = context.getString(R.string.notification_channel_description) val importance = NotificationManager.IMPORTANCE_DEFAULT - val mChannel = NotificationChannel(channelId, name, importance) - mChannel.description = descriptionText + val mChannel = NotificationChannel(channelId, name, importance).apply { + description = descriptionText + } notificationManager.createNotificationChannel(mChannel) - - notificationManager.notify(notification.notificationId.hashCode(), notificationBuilder.build()) } + + notificationManager.notify( + notification.notificationId.hashCode(), + notificationBuilder.build() + ) + } else { + Napier.e(tag = "NotificationError") { "Notification Manager is null" } + } + } ?: run { + Napier.e(tag = "NotificationError") { "Notification message is null" } } + } ?: run { + Napier.e(tag = "NotificationError") { "Notification title is null" } } } + override fun deleteNotificationFromSystem(notificationId: String) { context.getSystemService(NotificationManager::class.java)?.cancel(notificationId.hashCode()) } diff --git a/androidApp/src/main/res/values-de/error-strings.xml b/androidApp/src/main/res/values-de/error-strings.xml index 5edf1bc2..712f783f 100644 --- a/androidApp/src/main/res/values-de/error-strings.xml +++ b/androidApp/src/main/res/values-de/error-strings.xml @@ -9,4 +9,7 @@ Ortungsdienste sind deaktiviert Keine Berechtigung für den Zugriff auf die Ortungsdienste gewährt Kann nicht auf den Beschleunigungssensor zugreifen + Aufzeichnungfehler + Beobachtung kann nicht gestartet werden! Bitte stellen Sie sicher, dass Bluetooth aktiviert ist und alle notwendigen Geräte verbunden sind! + Fehler beim Fortsetzen der Beobachtung! Es gab ein Verbindungsproblem mit einem Bluetooth-Sensor. Bitte stellen Sie sicher, dass Bluetooth aktiviert ist und alle notwendigen Geräte verbunden sind! \ No newline at end of file diff --git a/androidApp/src/main/res/values/error-strings.xml b/androidApp/src/main/res/values/error-strings.xml index 988206d4..3adacffd 100644 --- a/androidApp/src/main/res/values/error-strings.xml +++ b/androidApp/src/main/res/values/error-strings.xml @@ -9,4 +9,7 @@ Location Servies are disabled No Permission were granted to access the location services Cannot access Accelerometer sensor + Observation Error + Cannot start Observation! Please make sure to enable bluetooth and connect all necessary devices! + Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices! \ No newline at end of file diff --git a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift index 6f079cd1..694163bb 100644 --- a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift +++ b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift @@ -39,6 +39,8 @@ class PolarVerityHeartRateObservation: Observation_ { private let deviceManager = BluetoothDeviceManager.shared private var deviceListener: Ktor_ioCloseable? + + private let errorStringTable = "Errors" init(sensorPermissions: Set) { super.init(observationType: PolarVerityHeartRateType(sensorPermissions: sensorPermissions)) @@ -57,12 +59,14 @@ class PolarVerityHeartRateObservation: Observation_ { }, onError: { [weak self] error in print(error) if let self { + showObservationErrorNotification(notificationBody: "Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!".localize(withComment: "Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!", useTable: errorStringTable), fallbackTitle: "Observation Error".localize(withComment: "Observation Error", useTable: errorStringTable)) self.pauseObservation(self.observationType) } }) return true } } + showObservationErrorNotification(notificationBody: "Cannot start Observation! Please make sure to enable Bluetooth and connect all necessary devices!".localize(withComment: "Cannot start Observation! Please make sure to enable Bluetooth and connect all necessary devices!", useTable: errorStringTable), fallbackTitle: "Observation Error".localize(withComment: "Observation Error", useTable: errorStringTable)) return false } diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/Errors.strings b/iosApp/iosApp/Resources/Strings/de.lproj/Errors.strings index 55838c4e..5a767af7 100644 --- a/iosApp/iosApp/Resources/Strings/de.lproj/Errors.strings +++ b/iosApp/iosApp/Resources/Strings/de.lproj/Errors.strings @@ -16,3 +16,6 @@ "Location Services not enabled" = "Ortungsdienste sind nicht aktiviert"; "Permission not granted to access location of the device" = "Zugriff auf den Standort des Geräts nicht genehmigt"; "errors" = "Fehler"; +"Cannot start Observation! Please make sure to enable Bluetooth and connect all necessary devices!" = "Beobachtung kann nicht gestartet werden! Bitte stellen Sie sicher, dass Bluetooth aktiviert ist und alle notwendigen Geräte verbunden sind!"; +"Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!" = "Fehler beim Fortsetzen der Beobachtung! Es gab ein Verbindungsproblem mit einem Bluetooth-Sensor. Bitte stellen Sie sicher, dass Bluetooth aktiviert ist und alle notwendigen Geräte verbunden sind!"; +"Observation Error" = "Aufzeichnungfehler"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/Errors.strings b/iosApp/iosApp/Resources/Strings/en.lproj/Errors.strings index 5e04d7d3..599fb5bf 100644 --- a/iosApp/iosApp/Resources/Strings/en.lproj/Errors.strings +++ b/iosApp/iosApp/Resources/Strings/en.lproj/Errors.strings @@ -16,3 +16,6 @@ "Location Services not enabled" = "Location Services not enabled"; "Permission not granted to access location of the device" = "Permission not granted to access location of the device"; "errors" = "errors"; +"Cannot start Observation! Please make sure to enable bluetooth and connect all necessary devices!" = "Cannot start Observation! Please make sure to enable bluetooth and connect all necessary devices!"; +"Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!" = "Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!"; +"Observation Error" = "Observation Error"; diff --git a/iosApp/iosApp/Services/LocalPushNotificationService.swift b/iosApp/iosApp/Services/LocalPushNotificationService.swift index c745a172..364f0712 100644 --- a/iosApp/iosApp/Services/LocalPushNotificationService.swift +++ b/iosApp/iosApp/Services/LocalPushNotificationService.swift @@ -7,27 +7,27 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // +import FirebaseMessaging import Foundation -import UserNotifications import shared -import FirebaseMessaging import UIKit +import UserNotifications class LocalPushNotifications: LocalNotificationListener { func clearNotifications() { UNUserNotificationCenter.current().removeAllDeliveredNotifications() } - + func deleteNotificationFromSystem(notificationId: String) { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [notificationId]) } - + func createNewFCMToken(onCompletion: @escaping (String) -> Void) { let notificationCenter = UNUserNotificationCenter.current() notificationCenter.getNotificationSettings { settings in @@ -35,7 +35,7 @@ class LocalPushNotifications: LocalNotificationListener { DispatchQueue.main.async { if !UIApplication.shared.isRegisteredForRemoteNotifications { AppDelegate.registerForNotifications() - } + } } Messaging.messaging().token { token, error in if let error { @@ -47,7 +47,7 @@ class LocalPushNotifications: LocalNotificationListener { } } } - + func deleteFCMToken() { Messaging.messaging().deleteToken { error in if let error = error { @@ -55,28 +55,30 @@ class LocalPushNotifications: LocalNotificationListener { } } } - + func displayNotification(notification: NotificationSchema) { if let title = notification.title, let body = notification.notificationBody { requestLocalNotification(identifier: notification.notificationId, title: title, subtitle: body) } } - + private func requestLocalNotification(identifier: String, title: String, subtitle: String, timeInterval: TimeInterval = 0, repeates: Bool = false) { let content = UNMutableNotificationContent() content.title = title content.subtitle = subtitle content.sound = .default - - var trigger: UNTimeIntervalNotificationTrigger? = nil - - if timeInterval > 0 { - trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: repeates) - } - + + let adjustedTimeInterval = max(timeInterval, 1) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: adjustedTimeInterval, repeats: repeates) + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) - - UNUserNotificationCenter.current().add(request) - print("Local Notification requested!") + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Error adding notification: \(error)") + } else { + print("Local Notification requested!") + } + } } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Shared.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Shared.kt index 1707a29c..6bf90564 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Shared.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Shared.kt @@ -120,7 +120,6 @@ class Shared( observationDataManager.listenToDatapointCountChanges() updateTaskStates() observationManager.activateScheduleUpdate() - } } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/NotificationSchema.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/NotificationSchema.kt index a5225c2e..3030e49c 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/NotificationSchema.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/NotificationSchema.kt @@ -17,8 +17,10 @@ import io.realm.kotlin.types.RealmInstant import io.realm.kotlin.types.RealmObject import io.realm.kotlin.types.annotations.PrimaryKey import io.redlink.more.more_app_mutliplatform.extensions.toRealmInstant +import io.redlink.more.more_app_mutliplatform.getPlatform import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.PushNotification import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager +import io.redlink.more.more_app_mutliplatform.util.createUUID import kotlinx.datetime.Instant class NotificationSchema : RealmObject { @@ -51,6 +53,14 @@ class NotificationSchema : RealmObject { } companion object { + fun build(title: String, notificationBody: String): NotificationSchema = + NotificationSchema().apply { + this.notificationId = createUUID() + this.title = title + this.notificationBody = notificationBody + this.priority = if (getPlatform().name.contains("Android")) 2 else 1 + } + fun toSchema( notificationId: String, channelId: String?, diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/Observation.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/Observation.kt index 727b5696..08697404 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/Observation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/Observation.kt @@ -12,6 +12,7 @@ package io.redlink.more.more_app_mutliplatform.observations import io.github.aakira.napier.Napier import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository +import io.redlink.more.more_app_mutliplatform.database.schemas.NotificationSchema import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationDataSchema import io.redlink.more.more_app_mutliplatform.models.ScheduleState import io.redlink.more.more_app_mutliplatform.observations.observationTypes.ObservationType @@ -21,7 +22,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import kotlinx.datetime.Instant @@ -70,7 +74,6 @@ abstract class Observation(val observationType: ObservationType) { stop { saveAndSend() observationShutdown(scheduleId) - updateObservationErrors() } } else { saveAndSend() @@ -78,6 +81,7 @@ abstract class Observation(val observationType: ObservationType) { if (removeNotification) { handleNotification(scheduleId) } + updateObservationErrors() } fun observationDataManagerAdded() = dataManager != null @@ -160,8 +164,8 @@ abstract class Observation(val observationType: ObservationType) { stop { saveAndSend() observationShutdown(scheduleId) - updateObservationErrors() } + updateObservationErrors() } fun stopAndSetState(state: ScheduleState = ScheduleState.ACTIVE, scheduleId: String?) { @@ -172,8 +176,8 @@ abstract class Observation(val observationType: ObservationType) { scheduleId?.let { observationShutdown(it) } - updateObservationErrors() } + updateObservationErrors() } fun stopAndSetDone(scheduleId: String) { @@ -184,6 +188,7 @@ abstract class Observation(val observationType: ObservationType) { observationShutdown(scheduleId) removeDataCount() handleNotification(scheduleId) + updateObservationErrors() } } @@ -209,6 +214,34 @@ abstract class Observation(val observationType: ObservationType) { } } + protected fun showNotification(title: String, notificationBody: String) { + val notification = NotificationSchema.build(title, notificationBody) + Napier.d(tag = "Observation::showNotification") { "Showing notification: $notification" } + notificationManager?.storeAndDisplayNotification(notification, true) + } + + protected fun showObservationErrorNotification( + notificationBody: String, + fallbackTitle: String = "Error" + ) { + val schedulesSchemaFlows = scheduleIds.keys.map { + scheduleRepository.scheduleWithId(it) + } + val combinedFlow = combine(schedulesSchemaFlows) { values -> + values.mapNotNull { it } + } + + StudyScope.launch { + val scheduleSchemas = combinedFlow.first() + val title = + if (scheduleSchemas.isNotEmpty()) scheduleSchemas.map { it.observationTitle } + .joinToString(", ", limit = 5) else fallbackTitle + withContext(Dispatchers.Main) { + showNotification(title, notificationBody) + } + } + } + protected fun saveAndSend() { Napier.d(tag = "Observation::finish") { "Saving and sending data for observation of type ${observationType.observationType}." } dataManager?.saveAndSend() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationManager.kt index 1eb3a2c5..1115442b 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationManager.kt @@ -89,6 +89,7 @@ class NotificationManager( if (notification.title != null && notification.notificationBody != null) { notificationRepository.storeNotification(notification) if (displayNotification) { + Napier.d(tag = "NotificationManager::storeAndDisplayNotification") { "Displaying notification: $notification" } localNotificationListener.displayNotification(notification) } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt index 5bfa8a2f..2e1d423f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt @@ -72,7 +72,9 @@ class BluetoothController( } } else { if (!bleViewHasBeenOpened) { - bleViewHasBeenOpened = ViewManager.showBLEView(true) + if (ViewManager.showBLEView(true)) { + bleViewHasBeenOpened = true + } } } return false @@ -89,7 +91,11 @@ class BluetoothController( enableBackgroundScanner() } } else { - ViewManager.showBLEView(true) + if (!bleViewHasBeenOpened) { + if (ViewManager.showBLEView(true)) { + bleViewHasBeenOpened = true + } + } } } }