From 69f138c8925d048cffc6068e95daf1464ec2d100 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Tue, 11 Aug 2026 10:10:59 +0200 Subject: [PATCH 1/6] 404 app tracking improvements (#70) * Uncommited changes from the user's working copy * Add comprehensive event tracking across app interactions * V1.0.6 upgrade * plan * #404: Implemented extensive App tracking * #404: Added more logs (cherry picked from commit cae86bfebcaa99e63cd5b8654e0b06b280dfe9b6) --- androidApp/build.gradle.kts | 3 +- .../NotificationService.swift | 15 + iosApp/iosApp/AppDelegate.swift | 20 ++ iosApp/iosApp/Services/FCMService.swift | 2 +- .../tracking/NotificationTrackingFlusher.kt | 18 ++ .../io/redlink/more/logging/KMMLogger.kt | 8 +- .../appUsage/AppUsageObservation.kt | 1 + .../observations/appUsage/model/LogEvent.kt | 28 ++ .../services/network/NetworkServiceImpl.kt | 4 +- .../more/services/network/demo/DemoData.kt | 269 ++++++++++++++++++ .../notification/NotificationManager.kt | 14 + .../tracking/NotificationTrackingFlusher.kt | 16 ++ .../redlink/more/viewModels/CoreViewModel.kt | 7 +- .../io/redlink/more/viewModels/ViewManager.kt | 8 +- .../tracking/NotificationTrackingFlusher.kt | 31 ++ 15 files changed, 432 insertions(+), 12 deletions(-) create mode 100644 shared/src/androidMain/kotlin/io/redlink/more/services/tracking/NotificationTrackingFlusher.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/network/demo/DemoData.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/tracking/NotificationTrackingFlusher.kt create mode 100644 shared/src/iosMain/kotlin/io/redlink/more/services/tracking/NotificationTrackingFlusher.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 9813b738..a4557d0b 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -63,7 +63,7 @@ android { defaultConfig { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 - targetSdk = 36 + targetSdk = 37 versionCode = 37 versionName = "5.0.0" } @@ -148,6 +148,7 @@ android { } isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/iosApp/BlendedCare-Notification-Service-Extension/NotificationService.swift b/iosApp/BlendedCare-Notification-Service-Extension/NotificationService.swift index 4cceae43..0cee82c2 100644 --- a/iosApp/BlendedCare-Notification-Service-Extension/NotificationService.swift +++ b/iosApp/BlendedCare-Notification-Service-Extension/NotificationService.swift @@ -11,6 +11,7 @@ import UserNotifications class NotificationService: UNNotificationServiceExtension { private static let appGroup = "group.ac.at.lbg.dhp.more.group" private static let notificationCountKey = "notification_count" + private static let pendingDeliveredEventsKey = "pending_notification_delivered_events" private static let STUDY_UPDATE_NOTIFICATION_KEY = "key" private static let STUDY_UPDATE_NOTIFICATION_VALUE = "STUDY_STATE_CHANGED" @@ -42,10 +43,24 @@ class NotificationService: UNNotificationServiceExtension { if let bestAttemptContent { bestAttemptContent.badge = NSNumber(value: adjusted) defaults?.set(adjusted, forKey: NotificationService.notificationCountKey) + recordPendingDeliveredEvent(for: request) contentHandler(bestAttemptContent) } } + /// Appends a lightweight delivery record to the shared app-group UserDefaults so the + /// main app can flush it as a NOTIFICATION_DELIVERED tracking event on next launch. + private func recordPendingDeliveredEvent(for request: UNNotificationRequest) { + let userInfo = request.content.userInfo + let msgId = (userInfo["gcm.message_id"] as? String) + ?? (userInfo["message_id"] as? String) + ?? request.identifier + + var pending = defaults?.array(forKey: NotificationService.pendingDeliveredEventsKey) as? [[String: String]] ?? [] + pending.append(["id": msgId, "timestamp": ISO8601DateFormatter().string(from: Date())]) + defaults?.set(pending, forKey: NotificationService.pendingDeliveredEventsKey) + } + override func serviceExtensionTimeWillExpire() { // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 42666352..ef2fbd25 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -71,9 +71,29 @@ class AppDelegate: NSObject, UIApplicationDelegate { AppDelegate.shared.deeplinkManager.setProtocol(protocolReplacement: Shared.companion.PROTOCOL.localized()) AppDelegate.shared.deeplinkManager.setHost(hostReplacement: Shared.companion.HOST.localized()) + flushPendingDeliveredNotifications() + return true } + /// Reads delivery records written by the Notification Service Extension (which cannot + /// access the KMP shared framework) and re-emits them as NOTIFICATION_DELIVERED tracking + /// events now that the main app process — and its tracking infrastructure — is running. + private func flushPendingDeliveredNotifications() { + let key = "pending_notification_delivered_events" + guard + let defaults = UserDefaults(suiteName: AppDelegate.appGroup), + let pending = defaults.array(forKey: key) as? [[String: String]], + !pending.isEmpty + else { return } + + for record in pending { + let id = record["id"] ?? "unknown" + Napier.event(.notificationDelivered, message: "id:\(id) (system)") + } + defaults.removeObject(forKey: key) + } + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { print("Notification Received: \(userInfo)") AppDelegate.shared.notificationManager.handleNotificationDataAsync(data: userInfo.notNilStringDictionary()) diff --git a/iosApp/iosApp/Services/FCMService.swift b/iosApp/iosApp/Services/FCMService.swift index 7eacfa05..30c4000d 100644 --- a/iosApp/iosApp/Services/FCMService.swift +++ b/iosApp/iosApp/Services/FCMService.swift @@ -40,7 +40,7 @@ extension FCMService: UNUserNotificationCenterDelegate { let content = notification.request.content let data = content.userInfo.notNilStringDictionary() if let msgId = data[NotificationManager.companion.MSG_ID] { - AppDelegate.shared.notificationManager.storeAndHandleNotification(key: msgId, title: content.title, body: content.body, priority: 1, read: false, completed: false, data: data, displayNotification: false) + AppDelegate.shared.notificationManager.storeAndHandleNotification(key: msgId, title: content.title, body: content.body, priority: 1, read: false, completed: false, data: data, displayNotification: true) } do { try await AppDelegate.shared.observationService.scheduleObservationReminder() diff --git a/shared/src/androidMain/kotlin/io/redlink/more/services/tracking/NotificationTrackingFlusher.kt b/shared/src/androidMain/kotlin/io/redlink/more/services/tracking/NotificationTrackingFlusher.kt new file mode 100644 index 00000000..1e0e905b --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/services/tracking/NotificationTrackingFlusher.kt @@ -0,0 +1,18 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ + +package io.redlink.more.services.tracking + +actual object NotificationTrackingFlusher { + actual fun flush() { + // Android: notification delivery is tracked directly at the point of delivery + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/logging/KMMLogger.kt b/shared/src/commonMain/kotlin/io/redlink/more/logging/KMMLogger.kt index 71c156db..53a02590 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/logging/KMMLogger.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/logging/KMMLogger.kt @@ -33,12 +33,16 @@ object KMMLogger { } fun event(event: LogEvent, message: String? = null) { - val logMessage = "[EVENT: ${event.key}] ${message ?: ""}".trim() - Napier.i(logMessage, tag = EVENT_TAG) EventCollection.logEvent(event, message) } } +fun LogEvent.track(data: Map = emptyMap()) { + val message = if (data.isEmpty()) null else data.entries.joinToString(",") { "${it.key}=${it.value}" } + EventCollection.logEvent(this, message) +} + +@Deprecated("Use LogEvent.track() instead", ReplaceWith("event.track()")) fun Napier.event(event: LogEvent, message: String? = null) { KMMLogger.event(event, message) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt index ab58cd0a..237c13f8 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt @@ -198,6 +198,7 @@ class AppUsageObservation( event: LogEvent, message: String? ) { + Napier.d { "New AppUsage Event: $event; Message: $message" } if (event == LogEvent.APP_TRACKING_ACCEPTED) { if (trackingApproval != PermissionApprovalState.GRANTED) { setTrackingApproval(true) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt index b1248209..d7d17a5f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt @@ -74,6 +74,34 @@ enum class LogEvent( key = "app_tracking_declined", storageMode = EventStorageMode.INSTANT, storeWithoutApproval = true + ), + BUTTON_CLICK( + key = "button_click", + storageMode = EventStorageMode.INSTANT + ), + DATE_SELECTION( + key = "date_selection", + storageMode = EventStorageMode.INSTANT + ), + STEP_VIEW_SUBMITTED( + key = "step_view_submitted", + storageMode = EventStorageMode.INSTANT + ), + NOTIFICATION_SHOWN( + key = "notification_shown", + storageMode = EventStorageMode.INSTANT + ), + NOTIFICATION_CLICKED( + key = "notification_clicked", + storageMode = EventStorageMode.INSTANT + ), + NOTIFICATION_DEEPLINK_OPENED( + key = "notification_deeplink_opened", + storageMode = EventStorageMode.INSTANT + ), + NOTIFICATION_DELIVERED( + key = "notification_delivered", + storageMode = EventStorageMode.INSTANT ); fun aggregateKey(identifier: String): String = diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkServiceImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkServiceImpl.kt index 19dc41a4..50c14384 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkServiceImpl.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkServiceImpl.kt @@ -27,7 +27,6 @@ import io.redlink.more.services.network.openapi.model.StudyConsent import io.redlink.more.services.store.CredentialRepository import io.redlink.more.services.store.EndpointRepository -private const val TAG = "NetworkService" class NetworkServiceImpl( endpointRepository: EndpointRepository, @@ -284,4 +283,7 @@ class NetworkServiceImpl( return NetworkServiceError(code, errorResponse) } + companion object { + private const val TAG = "NetworkService" + } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/network/demo/DemoData.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/network/demo/DemoData.kt new file mode 100644 index 00000000..7fc36f51 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/demo/DemoData.kt @@ -0,0 +1,269 @@ +package io.redlink.more.services.network.demo + +import io.redlink.more.services.network.openapi.model.ApiKey +import io.redlink.more.services.network.openapi.model.AppConfiguration +import io.redlink.more.services.network.openapi.model.ContactInfo +import io.redlink.more.services.network.openapi.model.Observation +import io.redlink.more.services.network.openapi.model.ObservationSchedule +import io.redlink.more.services.network.openapi.model.PushNotification +import io.redlink.more.services.network.openapi.model.SimpleParticipant +import io.redlink.more.services.network.openapi.model.Study +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray + +object DemoData { + fun createDemoStudy(): Study { + val now = Clock.System.now() + val today = now.toLocalDateTime(TimeZone.currentSystemDefault()).date + val observationStart = now.minus(1, DateTimeUnit.HOUR) + val observationEnd = now.plus(1, DateTimeUnit.HOUR) + + return Study( + studyTitle = "Demo Study", + participantInfo = "This is a demo study for testing purposes.", + consentInfo = "By using this demo, you agree to the mock terms and conditions.", + start = today.minus(1, DateTimeUnit.DAY), + end = today.plus(30, DateTimeUnit.DAY), + version = now.toEpochMilliseconds(), + active = true, + studyState = Study.StudyState.ACTIVE, + observations = listOf( +// Observation( +// observationId = "1", +// observationType = "question-observation", +// observationTitle = "Daily Mood", +// participantInfo = "Please rate your mood today.", +// configuration = buildJsonObject { +// put("question", "How are you feeling today?") +// putJsonArray("answers") { +// add(JsonPrimitive("Great")) +// add(JsonPrimitive("Good")) +// add(JsonPrimitive("Okay")) +// add(JsonPrimitive("Bad")) +// add(JsonPrimitive("Very Bad")) +// } +// }, +// schedule = listOf( +// ObservationSchedule( +// start = observationStart, +// end = observationEnd +// ) +// ), +// required = true, +// version = now.toEpochMilliseconds() +// ), +// Observation( +// observationId = "2", +// observationType = "multiple-choice-question-observation", +// observationTitle = "Symptoms", +// participantInfo = "Please select all symptoms you experienced today.", +// configuration = buildJsonObject { +// put("question", "Which symptoms did you have today?") +// putJsonArray("answers") { +// add(JsonPrimitive("Headache")) +// add(JsonPrimitive("Cough")) +// add(JsonPrimitive("Fever")) +// add(JsonPrimitive("Nausea")) +// add(JsonPrimitive("Fatigue")) +// } +// }, +// schedule = listOf( +// ObservationSchedule( +// start = observationStart, +// end = observationEnd +// ) +// ), +// required = false, +// version = now.toEpochMilliseconds() +// ), +// Observation( +// observationId = "3", +// observationType = "lime-survey-observation", +// observationTitle = "Health Questionnaire", +// participantInfo = "A more detailed health questionnaire.", +// schedule = listOf( +// ObservationSchedule( +// start = observationStart, +// end = observationEnd +// ) +// ), +// required = false, +// version = now.toEpochMilliseconds() +// ), + Observation( + observationId = "4", + observationType = "app-usage-observation", + observationTitle = "App Usage", + participantInfo = "Monitoring app usage for study purposes.", + noSchedule = true, + schedule = emptyList(), + required = false, + version = now.toEpochMilliseconds(), + hidden = true + ), +// Observation( +// observationId = "5", +// observationType = "acc-mobile-observation", +// observationTitle = "Activity Tracking", +// participantInfo = "Using the accelerometer to track activity.", +// schedule = listOf( +// ObservationSchedule( +// start = now.minus(24, DateTimeUnit.HOUR), +// end = now.plus(30, DateTimeUnit.DAY, TimeZone.currentSystemDefault()) +// ) +// ), +// required = false, +// version = now.toEpochMilliseconds() +// ), +// Observation( +// observationId = "6", +// observationType = "gps-mobile-observation", +// observationTitle = "Location Tracking", +// participantInfo = "Tracking location for study purposes.", +// schedule = listOf( +// ObservationSchedule( +// start = now.minus(24, DateTimeUnit.HOUR), +// end = now.plus(30, DateTimeUnit.DAY, TimeZone.currentSystemDefault()) +// ) +// ), +// required = false, +// version = now.toEpochMilliseconds() +// ), +// Observation( +// observationId = "7", +// observationType = "polar-verity-observation", +// observationTitle = "Heart Rate", +// participantInfo = "Heart rate monitoring via Polar sensor.", +// schedule = listOf( +// ObservationSchedule( +// start = now.minus(24, DateTimeUnit.HOUR), +// end = now.plus(30, DateTimeUnit.DAY, TimeZone.currentSystemDefault()) +// ) +// ), +// required = false, +// version = now.toEpochMilliseconds() +// ) + // hidden observation + Observation( + observationId = "1", + observationType = "question-observation", + observationTitle = "Daily Mood", + participantInfo = "Please rate your mood today.", + configuration = buildJsonObject { + put("question", "How are you feeling today?") + putJsonArray("answers") { + add(JsonPrimitive("Great")) + add(JsonPrimitive("Good")) + add(JsonPrimitive("Okay")) + add(JsonPrimitive("Bad")) + add(JsonPrimitive("Very Bad")) + } + }, + schedule = listOf( + ObservationSchedule( + start = observationStart, + end = observationEnd + ) + ), + hidden = true, + required = true, + reminder = true, + version = now.toEpochMilliseconds() + ), + // Boolean Goal Observation + ), + contact = ContactInfo( + person = "Demo Support", + email = "support@demo.more-platform.org", + institute = "Demo Institute of Health Research", + phoneNumber = "+1234567890" + ), + participant = SimpleParticipant( + id = 1, + alias = "Demo Participant", + ) + ) + } + + + fun getDemoNotifications(): List { + val now = Clock.System.now() + return listOf( + PushNotification( + type = PushNotification.Type.TEXT, + msgId = "demo_1", + title = "Welcome to the Study!", + body = "We are glad you are here. This is a demo notification.", + timestamp = now.plus(5, DateTimeUnit.MINUTE) + ), + PushNotification( + type = PushNotification.Type.TEXT, + msgId = "demo_2", + title = "Daily Mood Check", + body = "Please complete your mood check for today.", + timestamp = now.plus(10, DateTimeUnit.MINUTE), + deepLink = "more://task-details?observationId=1" + ), + PushNotification( + type = PushNotification.Type.TEXT, + msgId = "demo_3", + title = "New Observation available", + body = "A new questionnaire is waiting for you.", + timestamp = now.plus(15, DateTimeUnit.MINUTE), + deepLink = "more://task-details?observationId=2" + ), + PushNotification( + type = PushNotification.Type.TEXT, + msgId = "demo_general_1", + title = "Allgemeine Info", + body = "Dies ist eine wichtige Nachricht ohne direkte Aufgabe.", + timestamp = now.minus(2, DateTimeUnit.MINUTE) + ), + // Observation Reminder + PushNotification( + type = PushNotification.Type.TEXT, + msgId = "reminder_observation", + title = "Wie geht es dir?", + body = "Bitte fülle deinen täglichen Mood-Check aus.", + timestamp = now.minus(5, DateTimeUnit.MINUTE), + deepLink = "more://task-details?observationId=1&scheduleId=observation-schedule-1", + data = buildJsonObject { + put("observationId", "1") + put("scheduleId", "observation-schedule-1") + put( + "observationType", + io.redlink.more.observations.observationTypes.QuestionType().observationType + ) + put("reminderType", "reminder") + } + ), + ) + } + + fun getDemoSchedules(): List { + val now = Clock.System.now() + val observationStart = now.minus(1, DateTimeUnit.HOUR) + val observationEnd = now.plus(1, DateTimeUnit.HOUR) + return listOf( + ObservationSchedule( + start = observationStart, + end = observationEnd + ) + ) + } + + fun getDemoAppConfiguration(baseUrl: String): AppConfiguration { + return AppConfiguration( + credentials = ApiKey(apiId = "DEMO_ID", apiKey = "DEMO_KEY"), + endpoint = baseUrl + ) + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationManager.kt index 032a2aff..d5e5239d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationManager.kt @@ -17,6 +17,7 @@ import io.redlink.more.database.entities.ScheduleEntity import io.redlink.more.database.repository.MainRepository import io.redlink.more.extensions.mapQueryParams import io.redlink.more.extensions.toNotificationEntity +import io.redlink.more.logging.track import io.redlink.more.models.NotificationStatusType import io.redlink.more.models.ScheduleState import io.redlink.more.models.StudyState @@ -24,6 +25,7 @@ import io.redlink.more.navigation.DeeplinkManager import io.redlink.more.navigation.model.DeepLinkData import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.navigation.model.NavigationRouteParameter +import io.redlink.more.observations.appUsage.model.LogEvent import io.redlink.more.scopes.AppDispatchers import io.redlink.more.scopes.MoreDispatchers import io.redlink.more.scopes.Scope @@ -36,6 +38,7 @@ import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock interface LocalNotificationListener { fun displayNotification(notification: NotificationEntity, badgeCount: Int = 0) @@ -167,6 +170,11 @@ open class NotificationManager( Scope.launch { Napier.i { "Storing notification: ${notification.title} - ${notification.notificationBody}" } repository.notification.storeNotification(notification) + val now = Clock.System.now().epochSeconds + val triggerTime = notification.timestamp ?: now + if (triggerTime <= now + 1) { + LogEvent.NOTIFICATION_DELIVERED.track(mapOf("id" to notification.notificationId)) + } if (displayNotification) { Napier.d(tag = "NotificationManager::storeAndDisplayNotification") { "Displaying notification: $notification" } withContext(dispatchers.main) { @@ -187,6 +195,11 @@ open class NotificationManager( } fun displayNotification(notification: NotificationEntity) { + val now = Clock.System.now().epochSeconds + val triggerTime = notification.timestamp ?: now + if (triggerTime <= now + 1) { + LogEvent.NOTIFICATION_SHOWN.track(mapOf("id" to notification.notificationId)) + } localNotificationListener.displayNotification(notification, unreadUserCount.value) } @@ -259,6 +272,7 @@ open class NotificationManager( deepLink: String?, handler: ((NotificationActionHandler, DeepLinkData?) -> Unit) ) { + LogEvent.NOTIFICATION_CLICKED.track(mapOf("id" to notificationId)) deepLink?.let { Scope.launch { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/tracking/NotificationTrackingFlusher.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/tracking/NotificationTrackingFlusher.kt new file mode 100644 index 00000000..00f03b8e --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/tracking/NotificationTrackingFlusher.kt @@ -0,0 +1,16 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ + +package io.redlink.more.services.tracking + +expect object NotificationTrackingFlusher { + fun flush() +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt index 27c8e9db..5b361a60 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt @@ -10,9 +10,8 @@ */ package io.redlink.more.viewModels -import io.github.aakira.napier.Napier import io.ktor.utils.io.core.Closeable -import io.redlink.more.logging.event +import io.redlink.more.logging.track import io.redlink.more.observations.appUsage.model.LogEvent import io.redlink.more.scopes.AppDispatchers import kotlinx.coroutines.CoroutineScope @@ -28,11 +27,11 @@ abstract class CoreViewModel : Closeable { abstract fun viewIdentifier(): String open fun viewOpened() { - Napier.event(LogEvent.VIEW_OPEN, viewIdentifier()) + LogEvent.VIEW_OPEN.track(mapOf("view" to viewIdentifier())) } open fun viewClosed() { - Napier.event(LogEvent.VIEW_CLOSED, viewIdentifier()) + LogEvent.VIEW_CLOSED.track(mapOf("view" to viewIdentifier())) } open fun viewDidAppear() { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt index c533a374..c94644a7 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt @@ -13,9 +13,10 @@ package io.redlink.more.viewModels import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.github.aakira.napier.Napier -import io.redlink.more.logging.event +import io.redlink.more.logging.track import io.redlink.more.observations.Observation import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.services.tracking.NotificationTrackingFlusher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -133,10 +134,11 @@ object ViewManager { fun appIsInForeground(state: Boolean) { if (state) { - Napier.event(LogEvent.APP_IN_FOREGROUND) + LogEvent.APP_IN_FOREGROUND.track() Observation.resetRequestedPermissions() + NotificationTrackingFlusher.flush() } else { - Napier.event(LogEvent.APP_IN_BACKGROUND) + LogEvent.APP_IN_BACKGROUND.track() } _appInForeground.value = state } diff --git a/shared/src/iosMain/kotlin/io/redlink/more/services/tracking/NotificationTrackingFlusher.kt b/shared/src/iosMain/kotlin/io/redlink/more/services/tracking/NotificationTrackingFlusher.kt new file mode 100644 index 00000000..626056eb --- /dev/null +++ b/shared/src/iosMain/kotlin/io/redlink/more/services/tracking/NotificationTrackingFlusher.kt @@ -0,0 +1,31 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ + +package io.redlink.more.services.tracking + +import io.redlink.more.logging.track +import io.redlink.more.observations.appUsage.model.LogEvent +import platform.Foundation.NSUserDefaults + +private const val PENDING_EVENTS_KEY = "pending_notification_tracking_events" +private const val APP_GROUP = "group.io.redlink.umm.blendedcare.ios" + +actual object NotificationTrackingFlusher { + actual fun flush() { + val defaults = NSUserDefaults(suiteName = APP_GROUP) ?: return + val pending = defaults.stringArrayForKey(PENDING_EVENTS_KEY) ?: return + if (pending.isEmpty()) return + pending.forEach { _ -> + LogEvent.NOTIFICATION_DELIVERED.track() + } + defaults.removeObjectForKey(PENDING_EVENTS_KEY) + } +} From 763dccfeb90f48fe83366c24ce81b287fc8b737d Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Thu, 20 Aug 2026 10:44:01 +0200 Subject: [PATCH 2/6] Demo Mode integration --- .../more/app/android/MoreApplication.kt | 3 +- .../kotlin/io/redlink/more/Shared.kt | 13 +++- .../services/network/NetworkServiceProxy.kt | 77 +++++++++++++++++++ .../network/demo/DemoNetworkService.kt | 65 ++++++++++++++++ 4 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkServiceProxy.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/network/demo/DemoNetworkService.kt diff --git a/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt b/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt index d5db02a5..d48dc854 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt @@ -119,7 +119,8 @@ class MoreApplication : Application(), DefaultLifecycleObserver { repositories, sharedPreferences, ), - AndroidDataRecorder() + AndroidDataRecorder(), + isDebug = BuildConfig.DEBUG ) shared = tempShared tempShared.let { shared -> diff --git a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt index 6b93d699..22c3b193 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt @@ -34,6 +34,8 @@ import io.redlink.more.services.ObservationService import io.redlink.more.services.bluetooth.BluetoothConnector import io.redlink.more.services.network.NetworkService import io.redlink.more.services.network.NetworkServiceImpl +import io.redlink.more.services.network.NetworkServiceProxy +import io.redlink.more.services.network.demo.DemoNetworkService import io.redlink.more.services.network.openapi.model.Study import io.redlink.more.services.notification.LocalNotificationListener import io.redlink.more.services.notification.NotificationActionObserver @@ -72,7 +74,9 @@ open class Shared( val dataRecorder: DataRecorder, reminderNotificationSchedulingLimit: Int? = null, val connectionStatusFlow: Flow = - konnectionInstance().observeHasConnection() + konnectionInstance().observeHasConnection(), + val isDebug: Boolean = false, + ) : NotificationActionObserver, ExitStudyListener, AutoCloseable { val deeplinkManager: DeeplinkManager = DeeplinkManagerImpl(repositories, observationFactory) val endpointRepository: EndpointRepository = EndpointRepositoryImpl(sharedStorageRepository) @@ -80,8 +84,11 @@ open class Shared( CredentialRepositoryImpl(sharedStorageRepository).also { observationFactory.setCredentialsRepository(it) } - val networkService: NetworkService = - NetworkServiceImpl(endpointRepository, credentialRepository) + val networkService: NetworkService = NetworkServiceProxy( + NetworkServiceImpl(endpointRepository, credentialRepository), + if (isDebug) DemoNetworkService() else null, + sharedStorageRepository, + ) val observationManager = ObservationManager( repositories, diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkServiceProxy.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkServiceProxy.kt new file mode 100644 index 00000000..98634bff --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkServiceProxy.kt @@ -0,0 +1,77 @@ +package io.redlink.more.services.network + +import io.ktor.http.Url +import io.redlink.more.app.android.services.network.errors.NetworkServiceError +import io.redlink.more.models.CredentialModel +import io.redlink.more.models.LoginModel +import io.redlink.more.services.network.openapi.model.AppConfiguration +import io.redlink.more.services.network.openapi.model.DataBulk +import io.redlink.more.services.network.openapi.model.PushNotification +import io.redlink.more.services.network.openapi.model.Study +import io.redlink.more.services.network.openapi.model.StudyConsent +import io.redlink.more.services.store.SharedStorageRepository + +class NetworkServiceProxy( + private val realService: NetworkService, + private val demoService: NetworkService?, + private val sharedStorageRepository: SharedStorageRepository, +) : NetworkService { + private var activeService: NetworkService = + if (demoService != null && sharedStorageRepository.load(DEMO_MODE_KEY, false)) { + demoService + } else { + realService + } + + override fun baseUrl(): String { + return "demo.data.com" + } + + override suspend fun deleteParticipation(): Pair { + val (success, error) = activeService.deleteParticipation() + if (success) { + sharedStorageRepository.remove(DEMO_MODE_KEY) + activeService = realService + } + return success to error + } + + override suspend fun validateRegistrationToken(loginModel: LoginModel): Pair { + if (demoService != null && loginModel.token == "DEMO") { + sharedStorageRepository.store(DEMO_MODE_KEY, true) + activeService = demoService + } + return activeService.validateRegistrationToken(loginModel) + } + + override suspend fun sendConsent( + loginModel: LoginModel, + studyConsent: StudyConsent + ): Pair = + activeService.sendConsent(loginModel, studyConsent) + + override suspend fun getStudyConfig(credentials: CredentialModel?): Pair = + activeService.getStudyConfig(credentials) + + override suspend fun sendNotificationToken(token: String): Pair = + activeService.sendNotificationToken(token) + + override suspend fun sendData(data: DataBulk): Pair, NetworkServiceError?> = + activeService.sendData(data) + + override suspend fun downloadMissedNotifications(): List = + activeService.downloadMissedNotifications() + + override fun getBasicAuthHeader(): String? = activeService.getBasicAuthHeader() + override fun getGarminSSOUrl(): Url? = activeService.getGarminSSOUrl() + override fun garminSSOCallbackUrl(): Url? = activeService.garminSSOCallbackUrl() + override suspend fun garminSSOCallback(code: String, status: String): Boolean = + activeService.garminSSOCallback(code, status) + + override suspend fun deletePushNotification(msgId: String) = + activeService.deletePushNotification(msgId) + + companion object { + const val DEMO_MODE_KEY = "demo_mode" + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/network/demo/DemoNetworkService.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/network/demo/DemoNetworkService.kt new file mode 100644 index 00000000..e9b46ae0 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/demo/DemoNetworkService.kt @@ -0,0 +1,65 @@ +package io.redlink.more.services.network.demo + +import io.ktor.http.Url +import io.redlink.more.app.android.services.network.errors.NetworkServiceError +import io.redlink.more.models.CredentialModel +import io.redlink.more.models.LoginModel +import io.redlink.more.services.network.NetworkService +import io.redlink.more.services.network.openapi.model.AppConfiguration +import io.redlink.more.services.network.openapi.model.DataBulk +import io.redlink.more.services.network.openapi.model.PushNotification +import io.redlink.more.services.network.openapi.model.Study +import io.redlink.more.services.network.openapi.model.StudyConsent +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds + +class DemoNetworkService : NetworkService { + override fun baseUrl(): String { + return "https://demo.more-platform.org" + } + + + override suspend fun deleteParticipation(): Pair = + Pair(true, null) + + override suspend fun validateRegistrationToken(loginModel: LoginModel): Pair { + if (loginModel.token == "DEMO") { + return Pair(DemoData.createDemoStudy(), null) + } + return Pair(null, NetworkServiceError(404, "Not Found")) + } + + override suspend fun sendConsent( + loginModel: LoginModel, + studyConsent: StudyConsent + ): Pair { + return Pair(DemoData.getDemoAppConfiguration(baseUrl()), null) + } + + override suspend fun getStudyConfig(credentials: CredentialModel?): Pair { + return Pair(DemoData.createDemoStudy(), null) + } + + override suspend fun sendNotificationToken(token: String): Pair { + return Pair(true, null) + } + + override suspend fun sendData(data: DataBulk): Pair, NetworkServiceError?> { + delay(50.milliseconds) + return Pair(data.dataPoints.map { it.observationId }.toSet(), null) + } + + override suspend fun downloadMissedNotifications(): List { + return DemoData.getDemoNotifications() + } + + override fun getBasicAuthHeader(): String? = null + + override fun getGarminSSOUrl(): Url? = null + + override fun garminSSOCallbackUrl(): Url? = null + + override suspend fun garminSSOCallback(code: String, status: String): Boolean = true + + override suspend fun deletePushNotification(msgId: String) {} +} From 2e5d49479aa6adcf7d0c7b7b77d9dbc033bab0ef Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Thu, 20 Aug 2026 12:59:57 +0200 Subject: [PATCH 3/6] iOS Fix --- iosApp/iosApp/AppDelegate.swift | 9 ++++++++- iosApp/iosApp/Views/Consent/ConsentView.swift | 2 +- iosApp/iosApp/Views/Login/LoginView.swift | 2 +- iosApp/iosApp/Views/Login/ScanQRCodeView.swift | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index ef2fbd25..6b60f855 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -34,6 +34,13 @@ class AppDelegate: NSObject, UIApplicationDelegate { let dataManager = iOSObservationDataManager(repository: repositories, scope: Scope.shared, studyScope: StudyScope.shared, dispatchers: AppDispatchers.shared) let userDefaults = UserDefaultsRepository() + let isDebug: Bool = { + #if DEBUG + return true + #else + return false + #endif + }() return Shared( localNotificationListener: LocalPushNotifications(), repositories: repositories, @@ -43,7 +50,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { observationFactory: IOSObservationFactory(repository: repositories, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: 30, - connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() + connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: isDebug ) }() diff --git a/iosApp/iosApp/Views/Consent/ConsentView.swift b/iosApp/iosApp/Views/Consent/ConsentView.swift index 30c89ab5..1aaa8239 100644 --- a/iosApp/iosApp/Views/Consent/ConsentView.swift +++ b/iosApp/iosApp/Views/Consent/ConsentView.swift @@ -93,7 +93,7 @@ struct ConsentView: View { mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), - reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() + reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true ) let registration = RegistrationObservable(service: RegistrationService(shared: shared)) ConsentView(registration: registration) diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index b7a4831d..977914ce 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -152,7 +152,7 @@ struct LoginView: View { mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), - reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() + reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true ) let registration = RegistrationObservable(service: RegistrationService(shared: shared)) LoginView(registration: registration) diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index 6c567d39..c09a50d5 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -126,7 +126,7 @@ struct ScanQRCodeView: View { observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil, - connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() + connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true ) let registrationService = RegistrationService(shared: sharedContainer) ScanQRCodeView(model: LoginViewModel(registration: registrationService)) From 800f86d5e657bedcb7cd74d01e3ef28349591e2e Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:07:58 +0200 Subject: [PATCH 4/6] 413/412/414: Implement Health Connect / Apple Health observation support (#73) Cherry-picked from more-app-multiplatform (5a13252e) and reconciled to this repo's simpler schema/architecture: kept the Health Connect observation engine (healthConnect/ packages on all platforms, polling infrastructure, LatestObservationData storage, IconType-free ObservationType matching) and the small self-contained EventBus/TimedEventBus/DayMonitor/NetworkWatcher utilities, while dropping everything from upstream's unrelated parallel evolution: the Goals/adherence-check feature, the AppStores/HealthStore integration with the external PraeCura patient-record system, the offboarding flow, and the TodayListItemModel/ActionsManager UI-model layer (deferring the "latest value" Today-list display to a future task). * #412: Moved class into correct package * #412: Implemented Health connect Observation and collectors with stubs * #413: Apple Health implementation * #414: Google Health Connect implementation, added polling workers for data refresh in the background and fixed many bugs Co-Authored-By: Claude Sonnet 5 --- androidApp/build.gradle.kts | 15 +- androidApp/src/main/AndroidManifest.xml | 37 +- .../more/app/android/MoreApplication.kt | 6 + .../app/android/activities/ContentActivity.kt | 2 + .../activities/consent/ConsentViewModel.kt | 7 +- .../consent/composables/ConsentButtons.kt | 50 +- .../android/activities/main/MainActivity.kt | 14 +- .../android/activities/main/MainViewModel.kt | 7 +- .../observations/AndroidObservationFactory.kt | 13 + .../AndroidObservationPermissionObserver.kt | 44 ++ .../AndroidPollingTaskScheduler.kt | 42 + .../AndroidHealthConnectManager.kt | 341 +++++++++ .../AndroidHeartRateHealthConnectCollector.kt | 45 ++ .../AndroidStepsHealthConnectCollector.kt | 56 ++ .../more/app/android/workers/PollingWorker.kt | 48 ++ .../main/res/values/notification-strings.xml | 6 +- build.gradle.kts | 34 +- gradle/libs.versions.toml | 127 +++ iosApp/fastlane/Fastfile | 77 +- iosApp/iosApp.xcodeproj/project.pbxproj | 40 +- iosApp/iosApp/AppDelegate.swift | 5 +- .../IOSPollingTaskScheduler.swift | 13 + .../PollingBackgroundTask.swift | 70 ++ iosApp/iosApp/Info.plist | 6 +- iosApp/iosApp/InfoPlist.xcstrings | 36 +- .../AccelerometerBackgroundObservation.swift | 130 ---- .../AccelerometerRecorderCollector.swift | 60 ++ .../HeartRateHealthConnectCollector.swift | 57 ++ .../StepsHealthConnectCollector.swift | 81 ++ .../Observations/IOSObservationFactory.swift | 6 +- .../IOSObservationPermissionObserver.swift | 69 ++ iosApp/iosApp/Services/HealthKitManager.swift | 113 +++ iosApp/iosApp/Style/MoreColor.swift | 49 -- .../Views/Components/CheckboxField.swift | 2 +- iosApp/iosApp/Views/Consent/ConsentView.swift | 4 +- .../Views/Consent/ConsentViewModel.swift | 7 + iosApp/iosApp/Views/Login/LoginButton.swift | 2 +- iosApp/iosApp/Views/Login/LoginView.swift | 4 +- .../iosApp/Views/Login/ScanQRCodeView.swift | 5 +- .../Views/ObservationErrorListView.swift | 2 +- iosApp/iosApp/iosApp.entitlements | 2 + openapi/HealthTransformationAPI.yaml | 526 +++++++++++++ settings.gradle.kts | 2 + shared/build.gradle.kts | 19 +- .../kotlin/io/redlink/more/Platform.kt | 2 +- .../redlink/more/events/AndroidDayMonitor.kt | 115 +++ .../healthConnect/HealthConnectStrings.kt | 12 + .../services/network/AndroidNetworkWatcher.kt | 67 ++ .../kotlin/io/redlink/more/Constants.kt | 6 + .../kotlin/io/redlink/more/Shared.kt | 44 ++ .../io/redlink/more/database/AppDatabase.kt | 8 +- .../redlink/more/database/DatabaseManager.kt | 5 +- .../database/dao/LatestObservationDataDao.kt | 37 + .../entities/LatestObservationDataEntity.kt | 31 + .../more/database/migrations/Migration_3_4.kt | 33 + .../repository/ObservationRepository.kt | 9 +- .../repository/ObservationRepositoryImpl.kt | 41 +- .../redlink/more/events/DayChangeMonitor.kt | 9 + .../kotlin/io/redlink/more/events/EventBus.kt | 59 ++ .../io/redlink/more/events/TimedEventBus.kt | 130 ++++ .../more/extensions/DateTimeConverter.kt | 9 + .../redlink/more/extensions/JsonExtension.kt | 40 + .../extensions/ScheduleEntityExtenstion.kt | 2 + .../extensions/StringResourceExtensions.kt | 10 + .../formatter/HealthConnectValueFormatter.kt | 44 ++ .../redlink/more/models/DataDisplayValue.kt | 11 + .../redlink/more/observations/Observation.kt | 339 ++++++-- .../more/observations/ObservationFactory.kt | 87 ++- .../more/observations/ObservationManager.kt | 2 +- .../BackgroundAccelerometerCollector.kt | 20 + .../BackgroundAccelerometerObservation.kt | 162 ++++ .../healthConnect/HealthConnectCollector.kt | 47 ++ .../healthConnect/HealthConnectDataType.kt | 50 ++ .../healthConnect/HealthConnectObservation.kt | 347 +++++++++ .../HealthConnectObservationType.kt | 10 + .../healthConnect/HealthConnectStrings.kt | 17 + .../model/HealthConnectSample.kt | 61 ++ .../observationTypes/ObservationType.kt | 5 +- .../observers/ManualDataCollection.kt | 5 + .../polling/PollingObservationRegistry.kt | 63 ++ .../polling/PollingTaskScheduler.kt | 12 + .../more/registration/RegistrationService.kt | 51 +- .../more/services/network/NetworkWatcher.kt | 17 + .../more/services/network/demo/DemoData.kt | 31 + .../io/redlink/more/viewModels/ViewManager.kt | 8 + .../simpleQuestion/QuestionCoreViewModel.kt | 24 +- .../moko-resources/base/strings.xml | 545 +++++++++++++ .../commonMain/moko-resources/de/strings.xml | 571 ++++++++++++++ .../HealthConnectValueFormatterTest.kt | 82 ++ .../io/redlink/more/mocks/DatabaseMock.kt | 723 +++++++++++++++++- .../io/redlink/more/mocks/RepositoryMocks.kt | 23 + .../observations/ObservationManagerTest.kt | 61 +- .../BackgroundAccelerometerObservationTest.kt | 101 +++ .../appUsage/AppUsageObservationTest.kt | 39 +- .../HealthConnectObservationTest.kt | 468 ++++++++++++ .../HealthConnectObservationTypeTest.kt | 21 + .../polling/PollingObservationRegistryTest.kt | 78 ++ .../registration/RegistrationServiceTest.kt | 131 ++++ .../network/DemoNetworkServiceTest.kt | 97 +++ .../services/network/MockNetworkWatcher.kt | 24 + .../io/redlink/more/events/IosDayMonitor.kt | 98 +++ .../healthConnect/HealthConnectStrings.kt | 12 + 102 files changed, 6986 insertions(+), 451 deletions(-) create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidPollingTaskScheduler.kt create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/observations/healthConnect/AndroidHealthConnectManager.kt create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/observations/healthConnect/AndroidHeartRateHealthConnectCollector.kt create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/observations/healthConnect/AndroidStepsHealthConnectCollector.kt create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/workers/PollingWorker.kt create mode 100644 gradle/libs.versions.toml create mode 100644 iosApp/iosApp/BackgroundTasks/IOSPollingTaskScheduler.swift create mode 100644 iosApp/iosApp/BackgroundTasks/PollingBackgroundTask.swift delete mode 100644 iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift create mode 100644 iosApp/iosApp/Observations/AccelerometerRecorderCollector.swift create mode 100644 iosApp/iosApp/Observations/HealthObservationCollectors/HeartRateHealthConnectCollector.swift create mode 100644 iosApp/iosApp/Observations/HealthObservationCollectors/StepsHealthConnectCollector.swift create mode 100644 iosApp/iosApp/Services/HealthKitManager.swift delete mode 100644 iosApp/iosApp/Style/MoreColor.swift create mode 100644 openapi/HealthTransformationAPI.yaml create mode 100644 shared/src/androidMain/kotlin/io/redlink/more/events/AndroidDayMonitor.kt create mode 100644 shared/src/androidMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectStrings.kt create mode 100644 shared/src/androidMain/kotlin/io/redlink/more/services/network/AndroidNetworkWatcher.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/Constants.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/dao/LatestObservationDataDao.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/entities/LatestObservationDataEntity.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_3_4.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/events/DayChangeMonitor.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/events/EventBus.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/events/TimedEventBus.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/extensions/JsonExtension.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/extensions/StringResourceExtensions.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/formatter/HealthConnectValueFormatter.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/models/DataDisplayValue.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/accelerometer/BackgroundAccelerometerCollector.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/accelerometer/BackgroundAccelerometerObservation.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectCollector.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectDataType.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservation.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservationType.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectStrings.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/model/HealthConnectSample.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/observers/ManualDataCollection.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/polling/PollingObservationRegistry.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/polling/PollingTaskScheduler.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkWatcher.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/formatter/HealthConnectValueFormatterTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/observations/accelerometer/BackgroundAccelerometerObservationTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservationTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservationTypeTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/observations/polling/PollingObservationRegistryTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/registration/RegistrationServiceTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/services/network/DemoNetworkServiceTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/services/network/MockNetworkWatcher.kt create mode 100644 shared/src/iosMain/kotlin/io/redlink/more/events/IosDayMonitor.kt create mode 100644 shared/src/iosMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectStrings.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index a4557d0b..4219b373 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -3,13 +3,12 @@ import java.util.Base64 import java.util.Properties plugins { - id("com.android.application") - id("com.google.gms.google-services") - kotlin("android") - id("org.jetbrains.kotlin.plugin.compose") - id("com.google.firebase.crashlytics") - id("com.google.devtools.ksp") - + alias(libs.plugins.android.application) + alias(libs.plugins.google.services) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.firebase.crashlytics) + alias(libs.plugins.ksp) } fun loadEnvFromFile(): Properties { @@ -216,6 +215,8 @@ dependencies { implementation("androidx.room:room-runtime:$roomVersion") ksp("androidx.room:room-compiler:$roomVersion") + implementation(libs.health.connect.client) + implementation(platform("io.insert-koin:koin-bom:$koinVersion")) implementation("io.insert-koin:koin-core") diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 02f420b8..5afbe3b4 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -25,6 +25,11 @@ + + + + + @@ -36,6 +41,20 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt b/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt index d48dc854..e63e1a14 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt @@ -23,6 +23,7 @@ import io.redlink.more.app.android.extensions.applicationId import io.redlink.more.app.android.observations.AndroidDataRecorder import io.redlink.more.app.android.observations.AndroidObservationDataManager import io.redlink.more.app.android.observations.AndroidObservationFactory +import io.redlink.more.app.android.observations.AndroidPollingTaskScheduler import io.redlink.more.app.android.services.LocalPushNotificationService import io.redlink.more.app.android.services.bluetooth.PolarConnector import io.redlink.more.app.android.util.logging.FirebaseCrashlyticsAntilog @@ -30,8 +31,10 @@ import io.redlink.more.database.AppDatabase import io.redlink.more.database.getDatabaseBuilder import io.redlink.more.database.getRoomDatabase import io.redlink.more.database.repository.MainRepositoryImpl +import io.redlink.more.events.initPlatformContext import io.redlink.more.logging.napierDebugBuild import io.redlink.more.models.NotificationTextLocalization +import io.redlink.more.services.network.AndroidNetworkWatcher import io.redlink.more.services.store.SharedPreferencesRepository import io.redlink.more.viewModels.ViewManager @@ -101,6 +104,7 @@ class MoreApplication : Application(), DefaultLifecycleObserver { fun initShared(context: Context) { if (shared == null) { + initPlatformContext(context) polarConnector = PolarConnector(context) val androidBluetoothConnector = polarConnector!! val database: AppDatabase = getRoomDatabase(getDatabaseBuilder(context)) @@ -120,6 +124,8 @@ class MoreApplication : Application(), DefaultLifecycleObserver { sharedPreferences, ), AndroidDataRecorder(), + AndroidNetworkWatcher(context), + pollingTaskScheduler = AndroidPollingTaskScheduler(context), isDebug = BuildConfig.DEBUG ) shared = tempShared diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentActivity.kt index c7f2f765..42aed122 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentActivity.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentActivity.kt @@ -36,8 +36,10 @@ import kotlinx.coroutines.launch class ContentActivity : ComponentActivity() { private val viewModel = ContentViewModel() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + intent.getStringExtra(NotificationManager.DEEP_LINK)?.let { var deepLink = it Napier.d { "Received deep link: $deepLink" } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentViewModel.kt index b953b30d..a838b20e 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentViewModel.kt @@ -30,9 +30,12 @@ class ConsentViewModel( CoreConsentViewModel(registrationService, stringResource(R.string.consent_information)) fun acceptConsent(context: Context) { - getSecureID(context)?.let { uniqueDeviceId -> - registrationService.acceptConsent(uniqueDeviceId) + val uniqueDeviceId = getSecureID(context) + if (uniqueDeviceId == null) { + registrationService.cancelConsentSubmission() + return } + registrationService.acceptConsent(uniqueDeviceId) } fun openPermissionDeniedAlertDialog(context: Context, missingPermissions: List = emptyList()) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/composables/ConsentButtons.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/composables/ConsentButtons.kt index 61f8ff4c..0f26bf08 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/composables/ConsentButtons.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/composables/ConsentButtons.kt @@ -29,6 +29,7 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -44,11 +45,16 @@ import io.redlink.more.app.android.theme.MoreColors import io.redlink.more.logging.event import io.redlink.more.observations.appUsage.model.LogEvent import io.redlink.more.observations.observationTypes.AppUsageObservationType +import io.redlink.more.observations.healthConnect.HealthConnectObservationType +import io.redlink.more.scopes.Scope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch @Composable fun ConsentButtons(model: ConsentViewModel) { val isLoading by model.registrationService.isLoading.collectAsStateWithLifecycle() val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissionsMap -> @@ -74,7 +80,7 @@ fun ConsentButtons(model: ConsentViewModel) { deniedPermissions.map { PermissionUtils.getPermissionLabel(context, it) } ) } else { - model.acceptConsent(context) + requestHealthConnectPermissionsThenAcceptConsent(context, coroutineScope, model) } } @@ -89,7 +95,7 @@ fun ConsentButtons(model: ConsentViewModel) { Button( onClick = { Napier.event(LogEvent.BUTTON_PRESS, "Consent approved") - checkAndRequestPermissions(context, launcher, model) + checkAndRequestPermissions(context, launcher, model, coroutineScope) }, colors = ButtonDefaults .buttonColors( @@ -139,6 +145,7 @@ fun checkAndRequestPermissions( context: Context, launcher: ManagedActivityResultLauncher, Map>, model: ConsentViewModel, + coroutineScope: CoroutineScope, extraPermissions: Set = emptySet() ) { val permissions = @@ -165,9 +172,9 @@ fun checkAndRequestPermissions( } if (hasBackgroundLocationPermission) { - checkPermissionForBackgroundLocationAccess(context, launcher, model) + checkPermissionForBackgroundLocationAccess(context, launcher, model, coroutineScope) } else { - checkPermissions(context, launcher, permissions, model) + checkPermissions(context, launcher, permissions, model, coroutineScope) } } @@ -176,12 +183,13 @@ fun checkPermissions( launcher: ManagedActivityResultLauncher, Map>, permissions: Set, model: ConsentViewModel, + coroutineScope: CoroutineScope, ): Boolean { return if (!PermissionUtils.hasAllPermissions(permissions, context)) { launcher.launch(permissions.toTypedArray()) false } else { - model.acceptConsent(context) + requestHealthConnectPermissionsThenAcceptConsent(context, coroutineScope, model) true } } @@ -190,6 +198,7 @@ fun checkPermissionForBackgroundLocationAccess( context: Context, launcher: ManagedActivityResultLauncher, Map>, model: ConsentViewModel, + coroutineScope: CoroutineScope, ) { if (PermissionUtils.hasAllPermissions( setOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), @@ -205,14 +214,43 @@ fun checkPermissionForBackgroundLocationAccess( context, launcher, model, + coroutineScope, setOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION) ) dialog.dismiss() } .setNegativeButton("Decline") { dialog, _ -> - checkAndRequestPermissions(context, launcher, model) + checkAndRequestPermissions(context, launcher, model, coroutineScope) dialog.dismiss() } .create() .show() } + +/** + * Enqueues a Health Connect permission check for whichever subtypes the study actually needs (if + * any), by delegating to [HealthConnectObservation]'s own collector-aware permission check - the + * same logic that already runs at schedule-start time - instead of duplicating it here. This + * automatically scopes to active collectors, requests only what's missing, and shows the + * missing-permission alert on decline. + * + * The check runs on the app-wide [Scope] rather than [coroutineScope] (which is tied to this + * composition and dies when ContentActivity is torn down after consent completes), and does not + * block consent submission - AndroidHealthConnectManager queues the actual system prompt until + * MainActivity is resumed, so waiting for it here would only stall the consent flow. + */ +fun requestHealthConnectPermissionsThenAcceptConsent( + context: Context, + coroutineScope: CoroutineScope, + model: ConsentViewModel +) { + Scope.launch { + MoreApplication.shared?.observationFactory + ?.observation(HealthConnectObservationType().observationType) + ?.updateObservationPermissions() + } + coroutineScope.launch { + model.registrationService.beginConsentSubmission() + model.acceptConsent(context) + } +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainActivity.kt index 7d0b9508..e86ab5b8 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainActivity.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainActivity.kt @@ -60,6 +60,7 @@ import io.redlink.more.app.android.activities.studyStates.StudyUpdateView import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel import io.redlink.more.app.android.activities.tasks.TaskDetailsView import io.redlink.more.app.android.observations.PermissionUtils +import io.redlink.more.app.android.observations.healthConnect.AndroidHealthConnectManager import io.redlink.more.app.android.shared_composables.MoreBackground import io.redlink.more.app.android.util.ActivityProvider import io.redlink.more.models.ScheduleListType @@ -74,6 +75,8 @@ import kotlinx.coroutines.withContext class MainActivity : ComponentActivity() { private lateinit var navHostController: NavHostController + private lateinit var healthConnectLauncherOwnerToken: Any + override fun onResume() { super.onResume() ActivityProvider.setCurrentActivity(this) @@ -85,15 +88,22 @@ class MainActivity : ComponentActivity() { } override fun onDestroy() { - super.onDestroy() PermissionUtils.cleanupPermissionLauncher(this) + if (::healthConnectLauncherOwnerToken.isInitialized) { + AndroidHealthConnectManager.cleanupPermissionLauncher( + healthConnectLauncherOwnerToken + ) + } + super.onDestroy() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val viewModel = MainViewModel(this) + val viewModel = MainViewModel() PermissionUtils.initializePermissionLauncher(this) + healthConnectLauncherOwnerToken = + AndroidHealthConnectManager.initializePermissionLauncher(this) val activityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainViewModel.kt index 5c1e5fa9..11e5725d 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainViewModel.kt @@ -23,12 +23,13 @@ import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewMod import io.redlink.more.app.android.activities.observations.garmin.GarminConnectActivity import io.redlink.more.app.android.activities.observations.limeSurvey.LimeSurveyActivity import io.redlink.more.app.android.activities.studyDetails.observationDetails.ObservationDetailsViewModel +import io.redlink.more.app.android.util.ActivityProvider import io.redlink.more.models.ScheduleListType import io.redlink.more.viewModels.ViewManager import io.redlink.more.viewModels.notifications.CoreNotificationFilterViewModel import kotlinx.coroutines.launch -class MainViewModel(context: Context) : ViewModel() { +class MainViewModel : ViewModel() { val tabIndex = mutableIntStateOf(0) val showBackButton = mutableStateOf(false) val navigationBarTitle = mutableStateOf("") @@ -56,7 +57,9 @@ class MainViewModel(context: Context) : ViewModel() { viewModelScope.launch { ViewManager.bleViewActive.collect { if (it && !lastBleViewState) { - openBLESetupActivity(context) + ActivityProvider.getCurrentActivity()?.let { activity -> + openBLESetupActivity(activity) + } } lastBleViewState = it } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationFactory.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationFactory.kt index ba1b77d9..88a33b7b 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationFactory.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationFactory.kt @@ -16,12 +16,15 @@ import io.redlink.more.app.android.observations.GPS.GPSObservation import io.redlink.more.app.android.observations.GPS.GPSService import io.redlink.more.app.android.observations.HR.PolarHeartRateObservation import io.redlink.more.app.android.observations.accelerometer.AccelerometerObservation +import io.redlink.more.app.android.observations.healthConnect.AndroidHeartRateHealthConnectCollector +import io.redlink.more.app.android.observations.healthConnect.AndroidStepsHealthConnectCollector import io.redlink.more.app.android.services.sensorsListener.BluetoothStateListener import io.redlink.more.app.android.services.sensorsListener.GPSStateListener import io.redlink.more.database.repository.MainRepository import io.redlink.more.observations.Observation import io.redlink.more.observations.ObservationDataManager import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.healthConnect.HealthConnectObservation import io.redlink.more.scopes.AppDispatchers import io.redlink.more.scopes.MoreScope import io.redlink.more.scopes.Scope @@ -48,6 +51,16 @@ class AndroidObservationFactory( registerObservation { GPSObservation(context, repository, gpsService = GPSService(context)) } + registerObservation { + HealthConnectObservation( + repository, + this, + listOf( + AndroidHeartRateHealthConnectCollector(context), + AndroidStepsHealthConnectCollector(context) + ) + ) + } registerObservation { PolarHeartRateObservation(repository) } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationPermissionObserver.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationPermissionObserver.kt index 01d343a1..e8e2ba8a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationPermissionObserver.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationPermissionObserver.kt @@ -16,14 +16,19 @@ import android.content.Context import dev.icerock.moko.resources.desc.Resource import dev.icerock.moko.resources.desc.StringDesc import io.github.aakira.napier.Napier +import io.redlink.more.HEALTH_COLLECTOR_GROUP import io.redlink.more.SharedRes import io.redlink.more.app.android.MoreApplication +import io.redlink.more.app.android.observations.healthConnect.AndroidHealthConnectManager import io.redlink.more.app.android.util.ActivityProvider import io.redlink.more.dialog.AlertController import io.redlink.more.dialog.AlertDialogModel import io.redlink.more.logging.event +import io.redlink.more.observations.BundledPermissionCollector import io.redlink.more.observations.ObservationPermissionObserver +import io.redlink.more.observations.PermissionCollector import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.healthConnect.HealthConnectCollector import io.redlink.more.observations.observationTypes.AppUsageObservationType import io.redlink.more.observations.observationTypes.ObservationType import io.redlink.more.services.store.PermissionApprovalState @@ -88,4 +93,43 @@ class AndroidObservationPermissionObserver( MoreApplication.shared?.observationFactory?.stopRequestingPermissions() } } + + override suspend fun permissionStates(collectors: Collection): Map { + if (collectors.isEmpty()) { + return emptyMap() + } + return collectors.associate { it.permissionKey to it.permissionState() } + } + + override suspend fun requestPermissions(collectors: Collection) { + if (collectors.isEmpty()) return + MoreApplication.shared?.observationFactory?.startRequestingPermissions() + try { + val bundled = collectors.filterIsInstance() + val nonBundled = collectors.filter { it !is BundledPermissionCollector } + + val grouped = bundled.groupBy { it.permissionGroup } + for ((group, groupCollectors) in grouped) { + if (group == HEALTH_COLLECTOR_GROUP) { + val healthCollectors = + groupCollectors.filterIsInstance() + val metrics = healthCollectors + .flatMap { AndroidHealthConnectManager.metrics(it.dataType) } + .toSet() + if (metrics.isNotEmpty()) { + AndroidHealthConnectManager.requestPermissions(context, metrics) + } + } else { + for (collector in groupCollectors) { + collector.requestPermission() + } + } + } + for (collector in nonBundled) { + collector.requestPermission() + } + } finally { + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() + } + } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidPollingTaskScheduler.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidPollingTaskScheduler.kt new file mode 100644 index 00000000..d950a99f --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidPollingTaskScheduler.kt @@ -0,0 +1,42 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ +package io.redlink.more.app.android.observations + +import android.content.Context +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import io.redlink.more.app.android.workers.PollingWorker +import io.redlink.more.observations.polling.PollingTaskScheduler +import java.util.concurrent.TimeUnit + +/** + * Schedules the single, shared [PollingWorker] as a unique periodic WorkManager request - see + * [io.redlink.more.observations.polling.PollingObservationRegistry], which is the only caller and + * already avoids resubmitting an unchanged request. + */ +class AndroidPollingTaskScheduler(private val context: Context) : PollingTaskScheduler { + + override fun schedule(intervalMillis: Long) { + val intervalMinutes = (intervalMillis / 60_000L).coerceAtLeast(PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS / 60_000L) + val request = PeriodicWorkRequestBuilder(intervalMinutes, TimeUnit.MINUTES).build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + PollingWorker.WORKER_TAG, + ExistingPeriodicWorkPolicy.UPDATE, + request + ) + } + + override fun cancel() { + WorkManager.getInstance(context).cancelUniqueWork(PollingWorker.WORKER_TAG) + } +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/healthConnect/AndroidHealthConnectManager.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/healthConnect/AndroidHealthConnectManager.kt new file mode 100644 index 00000000..256142cf --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/healthConnect/AndroidHealthConnectManager.kt @@ -0,0 +1,341 @@ +package io.redlink.more.app.android.observations.healthConnect + +import android.content.Context +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.core.net.toUri +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.PermissionController +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.request.ReadRecordsRequest +import androidx.health.connect.client.time.TimeRangeFilter +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import dev.icerock.moko.resources.desc.Resource +import dev.icerock.moko.resources.desc.StringDesc +import io.github.aakira.napier.Napier +import io.redlink.more.SharedRes +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.observations.healthConnect.HealthConnectDataType +import io.redlink.more.observations.healthConnect.model.HealthConnectSample +import io.redlink.more.scopes.Scope +import io.redlink.more.services.store.PermissionApprovalState +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.time.Instant +import kotlin.time.toJavaInstant +import kotlin.time.toKotlinInstant + +object AndroidHealthConnectManager { + private const val PROVIDER_PACKAGE_NAME = "com.google.android.apps.healthdata" + + enum class Metric(val permission: String) { + HEART_RATE(HealthPermission.getReadPermission(HeartRateRecord::class)), + STEPS(HealthPermission.getReadPermission(StepsRecord::class)), + DISTANCE(HealthPermission.getReadPermission(DistanceRecord::class)) + } + + /** + * Every Health Connect metric a subtype needs, including bonus fields requested in the same + * permission dialog (steps also reads distance for the daily aggregate). Single source of + * truth so the permission-request path and each collector's own `requestPermission()` cannot + * diverge. + */ + fun metrics(dataType: HealthConnectDataType): Set = when (dataType) { + HealthConnectDataType.HEART_RATE -> setOf(Metric.HEART_RATE) + HealthConnectDataType.STEPS -> setOf(Metric.STEPS, Metric.DISTANCE) + } + + private val permissionRequestMutex = Mutex() + + private var launcherOwnerToken: Any? = null + private var permissionLauncher: ActivityResultLauncher>? = null + private var ownerLifecycle: Lifecycle? = null + private var pendingContinuation: CancellableContinuation>? = null + private var inFlightMetrics: Set? = null + private val pendingPermissionRequest = mutableSetOf() + private val deniedMetrics = mutableSetOf() + + /** + * Registers the permission launcher with the activity lifecycle. Requests made while the + * owning activity isn't RESUMED (e.g. mid-transition between activities) are queued and + * replayed the next time it resumes, instead of being launched from an activity that may be + * torn down before the system permission UI returns a result. + * + * The activity itself is not retained by this singleton. Instead, an opaque + * token is returned which the activity uses to clean up its registration. + */ + fun initializePermissionLauncher(activity: ComponentActivity): Any { + val ownerToken = Any() + + launcherOwnerToken = ownerToken + ownerLifecycle = activity.lifecycle + + permissionLauncher = activity.registerForActivityResult( + PermissionController.createRequestPermissionResultContract() + ) { granted -> + recordPermissionResult(granted) + pendingContinuation?.resumeWith(Result.success(granted)) + pendingContinuation = null + inFlightMetrics = null + } + + activity.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + replayPendingRequestIfAny(activity) + } + }) + + return ownerToken + } + + fun cleanupPermissionLauncher(ownerToken: Any) { + if (launcherOwnerToken === ownerToken) { + permissionLauncher = null + launcherOwnerToken = null + ownerLifecycle = null + + inFlightMetrics?.let { metrics -> + synchronized(pendingPermissionRequest) { + pendingPermissionRequest += metrics + } + } + inFlightMetrics = null + + pendingContinuation?.cancel() + pendingContinuation = null + } + } + + private fun recordPermissionResult(granted: Set) { + val requestedMetrics = inFlightMetrics ?: return + synchronized(deniedMetrics) { + requestedMetrics.forEach { metric -> + if (metric.permission in granted) { + deniedMetrics -= metric + } else { + deniedMetrics += metric + } + } + } + } + + fun isHealthConnectAvailable(context: Context): Boolean = + HealthConnectClient.getSdkStatus( + context.applicationContext, + PROVIDER_PACKAGE_NAME + ) == HealthConnectClient.SDK_AVAILABLE + + suspend fun permissionState( + context: Context, + metric: Metric + ): PermissionApprovalState { + val client = client(context) ?: return PermissionApprovalState.NOT_SET + val granted = client.permissionController.getGrantedPermissions() + + return when { + metric.permission in granted -> PermissionApprovalState.GRANTED + synchronized(deniedMetrics) { metric in deniedMetrics } -> PermissionApprovalState.DECLINED + else -> PermissionApprovalState.NOT_SET + } + } + + suspend fun requestPermission( + context: Context, + metric: Metric + ) = requestPermissions(context, setOf(metric)) + + suspend fun requestPermissions( + context: Context, + metrics: Set + ) = permissionRequestMutex.withLock { + if (metrics.isEmpty()) { + return@withLock + } + + if (!isHealthConnectAvailable(context)) { + promptInstall(context.applicationContext) + return@withLock + } + + val launcher = permissionLauncher + val ownerResumed = ownerLifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) == true + + if (launcher == null || !ownerResumed) { + Napier.w("HC: launcher not ready (launcher=${launcher != null}, ownerResumed=$ownerResumed), queueing") + + synchronized(pendingPermissionRequest) { + pendingPermissionRequest += metrics + } + + return@withLock + } + + val client = HealthConnectClient.getOrCreate( + context.applicationContext + ) + + val requestedPermissions = + metrics.map { it.permission }.toSet() + + val alreadyGranted = + client.permissionController.getGrantedPermissions() + + val missingPermissions = + requestedPermissions - alreadyGranted + + if (missingPermissions.isEmpty()) { + return@withLock + } + + suspendCancellableCoroutine> { continuation -> + pendingContinuation = continuation + inFlightMetrics = metrics + + continuation.invokeOnCancellation { + if (pendingContinuation === continuation) { + pendingContinuation = null + } + } + + Scope.launch { + withContext(Dispatchers.Main.immediate) { + launcher.launch(missingPermissions) + } + } + } + } + + private fun replayPendingRequestIfAny(activity: ComponentActivity) { + val metrics = synchronized(pendingPermissionRequest) { + if (pendingPermissionRequest.isEmpty()) { + return + } + + val set = pendingPermissionRequest.toSet() + pendingPermissionRequest.clear() + set + } + + activity.lifecycleScope.launch { + requestPermissions( + activity.applicationContext, + metrics + ) + } + } + + suspend fun readSamples( + context: Context, + metric: Metric, + from: Instant, + to: Instant + ): List { + val client = client(context) ?: return emptyList() + + val range = TimeRangeFilter.between( + from.toJavaInstant(), + to.toJavaInstant() + ) + + return when (metric) { + Metric.HEART_RATE -> client.readRecords( + ReadRecordsRequest( + HeartRateRecord::class, + range + ) + ).records.flatMap { record -> + record.samples.map { sample -> + HealthConnectSample.HeartRate( + timestamp = sample.time.toKotlinInstant(), + bpm = sample.beatsPerMinute.toInt(), + device = record.metadata.device?.model + ?: record.metadata.device?.manufacturer, + sourceApp = record.metadata.dataOrigin.packageName + ) + } + } + + Metric.STEPS -> client.readRecords( + ReadRecordsRequest( + StepsRecord::class, + range + ) + ).records.map { record -> + HealthConnectSample.Steps( + timestamp = record.endTime.toKotlinInstant(), + count = record.count, + start = record.startTime.toKotlinInstant(), + end = record.endTime.toKotlinInstant(), + device = record.metadata.device?.model ?: record.metadata.device?.manufacturer, + sourceApp = record.metadata.dataOrigin.packageName + ) + } + + // Distance has no per-interval HealthConnectSample representation - it is only ever + // summed for a window via readTotalDistance, matched into the steps daily aggregate. + Metric.DISTANCE -> emptyList() + } + } + + /** Total distance for [from, to), used to enrich the steps daily aggregate. */ + suspend fun readTotalDistance(context: Context, from: Instant, to: Instant): Double? { + val client = client(context) ?: return null + val range = TimeRangeFilter.between(from.toJavaInstant(), to.toJavaInstant()) + val records = client.readRecords(ReadRecordsRequest(DistanceRecord::class, range)).records + if (records.isEmpty()) return null + return records.sumOf { it.distance.inMeters } + } + + private fun client(context: Context): HealthConnectClient? { + val applicationContext = context.applicationContext + + return if (isHealthConnectAvailable(applicationContext)) { + HealthConnectClient.getOrCreate(applicationContext) + } else { + null + } + } + + private fun promptInstall(context: Context) { + AlertController.openAlertDialog( + AlertDialogModel( + title = StringDesc.Resource( + SharedRes.strings.health_connect_not_installed_title + ), + message = StringDesc.Resource( + SharedRes.strings.health_connect_not_installed_message + ), + confirmLabel = StringDesc.Resource( + SharedRes.strings.health_connect_install_button + ), + onConfirm = { + val uriString = + "market://details?id=$PROVIDER_PACKAGE_NAME&url=healthconnect%3A%2F%2Fonboarding" + + context.startActivity( + Intent(Intent.ACTION_VIEW).apply { + setPackage("com.android.vending") + data = uriString.toUri() + putExtra("overlay", true) + putExtra("callerId", context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + } + ) + ) + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/healthConnect/AndroidHeartRateHealthConnectCollector.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/healthConnect/AndroidHeartRateHealthConnectCollector.kt new file mode 100644 index 00000000..5e2f1ffc --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/healthConnect/AndroidHeartRateHealthConnectCollector.kt @@ -0,0 +1,45 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ +package io.redlink.more.app.android.observations.healthConnect + +import android.content.Context +import io.redlink.more.HEALTH_COLLECTOR_GROUP +import io.redlink.more.observations.healthConnect.HealthConnectCollector +import io.redlink.more.observations.healthConnect.HealthConnectDataType +import io.redlink.more.observations.healthConnect.model.HealthConnectSample +import io.redlink.more.services.store.PermissionApprovalState +import kotlin.time.Instant + +class AndroidHeartRateHealthConnectCollector(private val context: Context) : + HealthConnectCollector { + override val permissionGroup: String = HEALTH_COLLECTOR_GROUP + override val dataType: HealthConnectDataType = HealthConnectDataType.HEART_RATE + + override suspend fun permissionState(): PermissionApprovalState = + AndroidHealthConnectManager.permissionState( + context, + AndroidHealthConnectManager.Metric.HEART_RATE + ) + + override suspend fun requestPermission() = + AndroidHealthConnectManager.requestPermissions( + context, + AndroidHealthConnectManager.metrics(dataType) + ) + + override suspend fun collect(from: Instant, to: Instant): List = + AndroidHealthConnectManager.readSamples( + context, + AndroidHealthConnectManager.Metric.HEART_RATE, + from, + to + ) +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/healthConnect/AndroidStepsHealthConnectCollector.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/healthConnect/AndroidStepsHealthConnectCollector.kt new file mode 100644 index 00000000..29cd12a9 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/healthConnect/AndroidStepsHealthConnectCollector.kt @@ -0,0 +1,56 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ +package io.redlink.more.app.android.observations.healthConnect + +import android.content.Context +import io.redlink.more.HEALTH_COLLECTOR_GROUP +import io.redlink.more.observations.healthConnect.HealthConnectCollector +import io.redlink.more.observations.healthConnect.HealthConnectDataType +import io.redlink.more.observations.healthConnect.model.HealthConnectSample +import io.redlink.more.services.store.PermissionApprovalState +import kotlin.time.Instant + +class AndroidStepsHealthConnectCollector(private val context: Context) : HealthConnectCollector { + override val permissionGroup: String = HEALTH_COLLECTOR_GROUP + override val dataType: HealthConnectDataType = HealthConnectDataType.STEPS + + override suspend fun permissionState(): PermissionApprovalState = + AndroidHealthConnectManager.permissionState( + context, + AndroidHealthConnectManager.Metric.STEPS + ) + + override suspend fun requestPermission() = + // Requested together so distance (a "bonus" field on the daily aggregate, see + // collectDistanceInMeters) is covered by the same system prompt as steps - distance being + // denied must not block steps collection, so it is not checked in permissionState(). + AndroidHealthConnectManager.requestPermissions( + context, + AndroidHealthConnectManager.metrics(dataType) + ) + + override suspend fun collect(from: Instant, to: Instant): List = + AndroidHealthConnectManager.readSamples( + context, + AndroidHealthConnectManager.Metric.STEPS, + from, + to + ) + + override suspend fun collectDistanceInMeters(from: Instant, to: Instant): Double? = + AndroidHealthConnectManager.readTotalDistance(context, from, to) + + override suspend fun hasUnrequestedBonusPermission(): Boolean = + AndroidHealthConnectManager.permissionState( + context, + AndroidHealthConnectManager.Metric.DISTANCE + ) == PermissionApprovalState.NOT_SET +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/workers/PollingWorker.kt b/androidApp/src/main/java/io/redlink/more/app/android/workers/PollingWorker.kt new file mode 100644 index 00000000..6f6594a3 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/workers/PollingWorker.kt @@ -0,0 +1,48 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ +package io.redlink.more.app.android.workers + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import io.github.aakira.napier.Napier +import io.redlink.more.Shared +import io.redlink.more.app.android.MoreApplication +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Periodic background poll request triggered by [io.redlink.more.app.android.observations.AndroidPollingTaskScheduler], + * shared by every currently activated [io.redlink.more.observations.observers.ManualObserver] + * observation (e.g. Health Connect) - see [io.redlink.more.observations.polling.PollingObservationRegistry]. + */ +class PollingWorker(context: Context, workerParameters: WorkerParameters) : + CoroutineWorker(context, workerParameters) { + + private val shared: Shared + + init { + if (MoreApplication.shared == null) { + MoreApplication.initShared(applicationContext) + } + shared = MoreApplication.shared!! + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + Napier.i { "Running $WORKER_TAG! Polling active observations..." } + shared.observationFactory.pollActiveObservations() + Result.success() + } + + companion object { + const val WORKER_TAG = "PollingWorker" + } +} diff --git a/androidApp/src/main/res/values/notification-strings.xml b/androidApp/src/main/res/values/notification-strings.xml index 313c92e6..b0b3c487 100644 --- a/androidApp/src/main/res/values/notification-strings.xml +++ b/androidApp/src/main/res/values/notification-strings.xml @@ -2,9 +2,9 @@ io.redlink.more.app.android.urgent unread_notifications_channel - PraeCura - PraeCura Notification - The PraeCura Notification Channel provides newest information and health requests + MORE + MORE Notification + The MORE Notification Channel provides newest information and health requests %1$d unread notifications You have %1$d unread notifications. Please check them out. diff --git a/build.gradle.kts b/build.gradle.kts index 0e9fb93e..5938e3c0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,26 +1,16 @@ -buildscript { - dependencies { - classpath("com.google.gms:google-services:4.4.4") - classpath("com.google.firebase:firebase-crashlytics-gradle:3.0.6") - } - repositories { - google() // Google's Maven repository - mavenCentral() // Maven Central repository - } -} - plugins { - id("com.android.application").version("8.13.2").apply(false) - id("com.android.library").version("8.13.2").apply(false) - kotlin("android").version("2.3.10").apply(false) - kotlin("multiplatform").version("2.3.10").apply(false) - kotlin("plugin.serialization").version("2.3.10").apply(false) - id("org.jetbrains.kotlin.plugin.compose").version("2.3.10").apply(false) - id("androidx.room").version("2.8.4").apply(false) - id("com.google.devtools.ksp").version("2.3.5").apply(false) - - id("com.rickclephas.kmp.nativecoroutines").version("1.0.1").apply(false) - id("dev.icerock.mobile.multiplatform-resources").version("0.25.2").apply(false) + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.room) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.kmp.nativecoroutines) apply false + alias(libs.plugins.moko.resources) apply false + alias(libs.plugins.google.services) apply false + alias(libs.plugins.firebase.crashlytics) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..abe30fee --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,127 @@ +[versions] +androidGradlePlugin = "8.13.2" +firebaseCrashlyticsGradleVersion = "3.0.6" +googleServicesVersion = "4.4.4" +kotlin = "2.3.10" +kotlinKsp = "2.3.5" +firebaseBom = "34.17.0" +room = "2.8.4" +kmpNativeCoroutines = "1.0.1" +mokoResourcesPlugin = "0.25.2" +mokoResources = "0.25.2" +mokoGraphics = "0.10.1" +openApiGenerator = "7.17.0" +foojayResolverConvention = "1.0.0" + +compose = "1.11.4" +composeMaterial = "1.11.4" +composeMaterialIcons = "1.7.8" +composeMaterial3 = "1.4.0" +work = "2.11.2" +navigation = "2.9.8" +ktor = "3.5.2" +camera = "1.6.1" +polarBleSdk = "6.7.0" +rxjava = "3.1.12" +rxandroid = "3.0.2" +gson = "2.14.0" +fragment = "1.8.9" +activityCompose = "1.13.0" +playServicesLocation = "21.4.0" +napier = "2.7.1" +requestInspectorWebView = "1.0.3" +lifecycleProcess = "2.11.0" +mlkitBarcodeScanning = "17.3.0" +coroutines = "1.11.0" +serializationJson = "1.11.0" +kotlinxDatetime = "0.8.0" +sqlite = "2.7.0" +securityCrypto = "1.1.0" +kotlinCryptoHashBom = "0.8.0" +core = "1.19.0" +healthConnect = "1.1.0" + +[plugins] +android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "androidGradlePlugin" } +android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +room = { id = "androidx.room", version.ref = "room" } +ksp = { id = "com.google.devtools.ksp", version.ref = "kotlinKsp" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServicesVersion" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsGradleVersion" } +kmp-nativecoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "kmpNativeCoroutines" } +moko-resources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResourcesPlugin" } +openapi-generator = { id = "org.openapi.generator", version.ref = "openApiGenerator" } +foojay-resolver-convention = { id = "org.gradle.toolchains.foojay-resolver-convention", version.ref = "foojayResolverConvention" } + +[libraries] +firebase-crashlytics-gradle = { module = "com.google.firebase:firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsGradleVersion" } +google-services = { module = "com.google.gms:google-services", version.ref = "googleServicesVersion" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } + +compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } +compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } +compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } +compose-material = { module = "androidx.compose.material:material", version.ref = "composeMaterial" } +compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "composeMaterial3" } +compose-material3-window-size = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "composeMaterial3" } +compose-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "composeMaterialIcons" } +compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "composeMaterialIcons" } + +fragment = { module = "androidx.fragment:fragment", version.ref = "fragment" } +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } +work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } +play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } + +napier = { module = "io.github.aakira:napier", version.ref = "napier" } +polar-ble-sdk = { module = "com.github.polarofficial:polar-ble-sdk", version.ref = "polarBleSdk" } +rxjava = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjava" } +rxandroid = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid" } + +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } +firebase-analytics = { module = "com.google.firebase:firebase-analytics" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging" } +firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } +firebase-inappmessaging = { module = "com.google.firebase:firebase-inappmessaging" } +firebase-inappmessaging-display = { module = "com.google.firebase:firebase-inappmessaging-display" } + +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +request-inspector-webview = { module = "com.github.acsbendi:Android-Request-Inspector-WebView", version.ref = "requestInspectorWebView" } +lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleProcess" } + +mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkitBarcodeScanning" } +camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } +camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } +camera-view = { module = "androidx.camera:camera-view", version.ref = "camera" } + +moko-resources = { module = "dev.icerock.moko:resources", version.ref = "mokoResources" } +moko-resources-compose = { module = "dev.icerock.moko:resources-compose", version.ref = "mokoResources" } +moko-resources-test = { module = "dev.icerock.moko:resources-test", version.ref = "mokoResources" } +moko-graphics = { module = "dev.icerock.moko:graphics", version.ref = "mokoGraphics" } + +room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } + +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serializationJson" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } +kotlincrypto-hash-bom = { module = "org.kotlincrypto.hash:bom", version.ref = "kotlinCryptoHashBom" } +kotlincrypto-hash-md = { module = "org.kotlincrypto.hash:md" } + +security-crypto-ktx = { module = "androidx.security:security-crypto-ktx", version.ref = "securityCrypto" } +core = { group = "androidx.core", name = "core", version.ref = "core" } +health-connect-client = { module = "androidx.health.connect:connect-client", version.ref = "healthConnect" } \ No newline at end of file diff --git a/iosApp/fastlane/Fastfile b/iosApp/fastlane/Fastfile index 31aac9ad..ca2ddf6e 100644 --- a/iosApp/fastlane/Fastfile +++ b/iosApp/fastlane/Fastfile @@ -32,34 +32,63 @@ platform :ios do end end - before_all do - setup_google_services - create_keychain( - name: "temp_keychain", - password: ENV["FASTLANE_KEYCHAIN_PASSWORD"], - default_keychain: true, - unlock: true, - timeout: 3600, - lock_when_sleeps: false - ) + before_all do + setup_google_services - api_key = app_store_connect_api_key( - key_id: ENV['APPLE_CONNECT_KEY_ID'], - issuer_id: ENV['APPLE_CONNECT_ISSUER_ID'], - key_content: ENV['APPLE_CONNECT_KEY_CONTENT'], - is_key_content_base64: true, - duration: 1000 - ) - lane_context[SharedValues::APP_STORE_CONNECT_API_KEY] = api_key + keychain_name = "fastlane_temp_#{Process.pid}" + keychain_path = File.expand_path("~/Library/Keychains/#{keychain_name}-db") + + lane_context[:TEMP_KEYCHAIN_NAME] = keychain_name + lane_context[:TEMP_KEYCHAIN_PATH] = keychain_path + + # Remove stale keychain from a previous interrupted run + delete_keychain(name: keychain_name) if File.exist?(keychain_path) + + create_keychain( + name: keychain_name, + password: ENV["FASTLANE_KEYCHAIN_PASSWORD"], + default_keychain: false, + unlock: true, + timeout: 3600, + lock_when_sleeps: false + ) + + api_key = app_store_connect_api_key( + key_id: ENV["APPLE_CONNECT_KEY_ID"], + issuer_id: ENV["APPLE_CONNECT_ISSUER_ID"], + key_content: ENV["APPLE_CONNECT_KEY_CONTENT"], + is_key_content_base64: true, + duration: 1000 + ) + + lane_context[SharedValues::APP_STORE_CONNECT_API_KEY] = api_key + + if ENV["FASTLANE_MATCH_SECRET"] && !ENV["FASTLANE_MATCH_SECRET"].empty? + ENV["MATCH_PASSWORD"] = ENV["FASTLANE_MATCH_SECRET"] + end + end + + private_lane :cleanup_temp_keychain do + keychain_name = lane_context[:TEMP_KEYCHAIN_NAME] + keychain_path = lane_context[:TEMP_KEYCHAIN_PATH] + + next if keychain_name.to_s.empty? + + begin + delete_keychain(name: keychain_name) if keychain_path && File.exist?(keychain_path) + rescue => e + UI.important("Failed to delete temporary keychain #{keychain_name}: #{e.message}") + end + end - if ENV["FASTLANE_MATCH_SECRET"] && !ENV["FASTLANE_MATCH_SECRET"].empty? - ENV["MATCH_PASSWORD"] = ENV["FASTLANE_MATCH_SECRET"] + after_all do |_lane| + cleanup_temp_keychain end - end - after_all do |lane| - delete_keychain(name: "temp_keychain") if File.exist?(File.expand_path("~/Library/Keychains/temp_keychain-db")) - end + error do |lane, exception| + cleanup_temp_keychain + UI.error("Lane #{lane} failed: #{exception.message}") + end private_lane :generate_openapi do gradle( diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index b2898f64..4968c4bf 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -62,7 +62,7 @@ 1F43DE232EC6137F00B6F07B /* GarminConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F43DE222EC6137F00B6F07B /* GarminConnectViewModel.swift */; }; 1F45E5BB2F288D7500EE8487 /* ExitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F45E5BA2F288D7500EE8487 /* ExitButton.swift */; }; 1F45E5BD2F288DD600EE8487 /* ReloadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F45E5BC2F288DD600EE8487 /* ReloadButton.swift */; }; - 1F5A248D29C893B3008140CF /* AccelerometerBackgroundObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F5A248C29C893B3008140CF /* AccelerometerBackgroundObservation.swift */; }; + 1F5A248D29C893B3008140CF /* AccelerometerRecorderCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F5A248C29C893B3008140CF /* AccelerometerRecorderCollector.swift */; }; 1F5F842629E6C67A0010C2D2 /* LocalPushNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F5F842529E6C67A0010C2D2 /* LocalPushNotificationService.swift */; }; 1F60C58629951A5F00858581 /* ErrorText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F60C58529951A5F00858581 /* ErrorText.swift */; }; 1F638B7429D6B46300455B66 /* CMLogItemExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F638B7329D6B46300455B66 /* CMLogItemExtension.swift */; }; @@ -72,6 +72,9 @@ 1F6A4E3E29F6D0D200F0247F /* BluetoothDeviceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6A4E3D29F6D0D200F0247F /* BluetoothDeviceExtension.swift */; }; 1F6C31512A121EA500EED533 /* WebViewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6C31502A121EA500EED533 /* WebViewViewModel.swift */; }; 1F6C31532A13EB7F00EED533 /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1F6C31522A13EB7F00EED533 /* Launch Screen.storyboard */; }; + 1F6F8F3F302B580C0012A1A9 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1F6F8F3E302B580C0012A1A9 /* GoogleService-Info.plist */; }; + 1F6F8F42302C3ACC0012A1A9 /* StepsHealthConnectCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6F8F41302C3ACC0012A1A9 /* StepsHealthConnectCollector.swift */; }; + 1F6F8F43302C3ACC0012A1A9 /* HeartRateHealthConnectCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6F8F40302C3ACC0012A1A9 /* HeartRateHealthConnectCollector.swift */; }; 1F750CAD2A6FA771006E455E /* StudyPausedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F750CAC2A6FA771006E455E /* StudyPausedView.swift */; }; 1F750CAF2A6FA8AE006E455E /* StudyClosedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F750CAE2A6FA8AE006E455E /* StudyClosedView.swift */; }; 1F7F094E29D40EC800081B88 /* ObservationDataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F7F094D29D40EC800081B88 /* ObservationDataCollector.swift */; }; @@ -137,7 +140,10 @@ 1FF8D29E2F486B5500C57A01 /* MultiChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF8D29D2F486B5500C57A01 /* MultiChoiceView.swift */; }; 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; 3007C4E152846B66C9FD5A69 /* SetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3007C5E9B53BC561D105D2B3 /* SetExtension.swift */; }; + 5AEA0DB1FAD1484C9BA099EF /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE08ECBBA81140CBBD885A9E /* HealthKitManager.swift */; }; + 721589241F1D83D4AB9F03BF /* IOSPollingTaskScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE8B6D852253640DD777AAA /* IOSPollingTaskScheduler.swift */; }; 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; + 90B2B1A91826796667C87F9A /* PollingBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEA5E1EE2CB59547A4884A1 /* PollingBackgroundTask.swift */; }; B70888492DF2D4290048A4AC /* QRCodeScanDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70888482DF2D4220048A4AC /* QRCodeScanDelegate.swift */; }; B708884B2DF2E6730048A4AC /* ScanQrCodeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B708884A2DF2E66E0048A4AC /* ScanQrCodeViewModel.swift */; }; B708884D2DF2EE600048A4AC /* CameraPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B708884C2DF2EE510048A4AC /* CameraPreviewView.swift */; }; @@ -279,7 +285,8 @@ 1F43DE222EC6137F00B6F07B /* GarminConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminConnectViewModel.swift; sourceTree = ""; }; 1F45E5BA2F288D7500EE8487 /* ExitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExitButton.swift; sourceTree = ""; }; 1F45E5BC2F288DD600EE8487 /* ReloadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReloadButton.swift; sourceTree = ""; }; - 1F5A248C29C893B3008140CF /* AccelerometerBackgroundObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccelerometerBackgroundObservation.swift; sourceTree = ""; }; + 1F5A248C29C893B3008140CF /* AccelerometerRecorderCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccelerometerRecorderCollector.swift; sourceTree = ""; }; + 1F5BEBE12FE9219100224B4C /* AlertBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertBannerView.swift; sourceTree = ""; }; 1F5F842529E6C67A0010C2D2 /* LocalPushNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPushNotificationService.swift; sourceTree = ""; }; 1F60C58529951A5F00858581 /* ErrorText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorText.swift; sourceTree = ""; }; 1F638B7329D6B46300455B66 /* CMLogItemExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMLogItemExtension.swift; sourceTree = ""; }; @@ -289,6 +296,9 @@ 1F6A4E3D29F6D0D200F0247F /* BluetoothDeviceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothDeviceExtension.swift; sourceTree = ""; }; 1F6C31502A121EA500EED533 /* WebViewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewViewModel.swift; sourceTree = ""; }; 1F6C31522A13EB7F00EED533 /* Launch Screen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = ""; }; + 1F6F8F3E302B580C0012A1A9 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 1F6F8F40302C3ACC0012A1A9 /* HeartRateHealthConnectCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeartRateHealthConnectCollector.swift; sourceTree = ""; }; + 1F6F8F41302C3ACC0012A1A9 /* StepsHealthConnectCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepsHealthConnectCollector.swift; sourceTree = ""; }; 1F750CAC2A6FA771006E455E /* StudyPausedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyPausedView.swift; sourceTree = ""; }; 1F750CAE2A6FA8AE006E455E /* StudyClosedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyClosedView.swift; sourceTree = ""; }; 1F7F094D29D40EC800081B88 /* ObservationDataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationDataCollector.swift; sourceTree = ""; }; @@ -357,6 +367,9 @@ 7555FF7B242A565900829871 /* More.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = More.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 7EE8B6D852253640DD777AAA /* IOSPollingTaskScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSPollingTaskScheduler.swift; sourceTree = ""; }; + 9EEA5E1EE2CB59547A4884A1 /* PollingBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollingBackgroundTask.swift; sourceTree = ""; }; + AE08ECBBA81140CBBD885A9E /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = ""; }; B70888482DF2D4220048A4AC /* QRCodeScanDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScanDelegate.swift; sourceTree = ""; }; B708884A2DF2E66E0048A4AC /* ScanQrCodeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQrCodeViewModel.swift; sourceTree = ""; }; B708884C2DF2EE510048A4AC /* CameraPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPreviewView.swift; sourceTree = ""; }; @@ -476,6 +489,7 @@ children = ( 1F6A4E3A29F6C94B00F0247F /* Bluetooth */, 07EF6C4029B5E0C700CEF37D /* PermissionManager.swift */, + AE08ECBBA81140CBBD885A9E /* HealthKitManager.swift */, 1FE4446F29C849DC006AA11C /* Semaphore.swift */, 1F0026DE29CCA24F0034EF65 /* DataUploadManager.swift */, ED6A7F3E29DC405B00E266EC /* FCMService.swift */, @@ -491,6 +505,8 @@ 1F988B3E2F2BA0CA0094F99F /* DailyBackgroundTask.swift */, 1F0026DC29CC8F710034EF65 /* BackgroundTaskHandler.swift */, 1FC4F87329D2B86100F65026 /* DataUploadBackgroundTask.swift */, + 9EEA5E1EE2CB59547A4884A1 /* PollingBackgroundTask.swift */, + 7EE8B6D852253640DD777AAA /* IOSPollingTaskScheduler.swift */, ); path = BackgroundTasks; sourceTree = ""; @@ -510,6 +526,7 @@ 1F34796F29B8AFCB0030CA15 /* Observations */ = { isa = PBXGroup; children = ( + 1F6F8F44302C3AD40012A1A9 /* HealthObservationCollectors */, 1FA044452F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift */, 1F34797029B8AFE10030CA15 /* IOSObservationFactory.swift */, 1F34797429B8BECB0030CA15 /* AccelerometerObservation.swift */, @@ -517,7 +534,7 @@ 1FE4447129C85D94006AA11C /* IOSDataRecorder.swift */, 07D9046729C85166003D2912 /* GPSObservation.swift */, EDACF62829D2FB200032327B /* PolarVerityHeartRateObservation.swift */, - 1F5A248C29C893B3008140CF /* AccelerometerBackgroundObservation.swift */, + 1F5A248C29C893B3008140CF /* AccelerometerRecorderCollector.swift */, 1F7F094D29D40EC800081B88 /* ObservationDataCollector.swift */, 1F8EA2D22A0CC7D600F32602 /* ObservationActionDelegate.swift */, ); @@ -581,6 +598,15 @@ path = WebView; sourceTree = ""; }; + 1F6F8F44302C3AD40012A1A9 /* HealthObservationCollectors */ = { + isa = PBXGroup; + children = ( + 1F6F8F40302C3ACC0012A1A9 /* HeartRateHealthConnectCollector.swift */, + 1F6F8F41302C3ACC0012A1A9 /* StepsHealthConnectCollector.swift */, + ); + path = HealthObservationCollectors; + sourceTree = ""; + }; 1F8847B829914BD50023EF10 /* Components */ = { isa = PBXGroup; children = ( @@ -1164,8 +1190,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1F89378D2BFF1F8D0083D20E /* ObservationErrorsViewModel.swift in Sources */, - 1F5A248D29C893B3008140CF /* AccelerometerBackgroundObservation.swift in Sources */, + 1F5A248D29C893B3008140CF /* AccelerometerRecorderCollector.swift in Sources */, 1FF5B28D2A8275790076EF8E /* Bundle.swift in Sources */, 1F29C68C2E7A78CA003693C5 /* StudyLoadingView.swift in Sources */, 1FA763E82A42F834007C1CF9 /* NotificationFilterViewModel.swift in Sources */, @@ -1174,6 +1199,8 @@ 1F13BA432993951200938C1E /* ConsentList.swift in Sources */, 1F8847E6299182C90023EF10 /* LoginButton.swift in Sources */, 1F0A11C82F333C0F00EAE237 /* ObservationReminderBackgroundTask.swift in Sources */, + 90B2B1A91826796667C87F9A /* PollingBackgroundTask.swift in Sources */, + 721589241F1D83D4AB9F03BF /* IOSPollingTaskScheduler.swift in Sources */, EDD1DEFF29F7B595009BC8FB /* ScheduleListHeader.swift in Sources */, 1F7F094E29D40EC800081B88 /* ObservationDataCollector.swift in Sources */, B79DBA3929D3130600A1F547 /* ExpandableInput.swift in Sources */, @@ -1202,6 +1229,8 @@ 1F80EC4529C2524F004667B1 /* NavigationScreen.swift in Sources */, 1F750CAD2A6FA771006E455E /* StudyPausedView.swift in Sources */, EDEF4C8329EFD4CA00E830DA /* RunningSchedules.swift in Sources */, + 1F6F8F42302C3ACC0012A1A9 /* StepsHealthConnectCollector.swift in Sources */, + 1F6F8F43302C3ACC0012A1A9 /* HeartRateHealthConnectCollector.swift in Sources */, 1F8847D829915C030023EF10 /* MoreTextField.swift in Sources */, 1F0026DF29CCA24F0034EF65 /* DataUploadManager.swift in Sources */, 1F43997C29B8D70800687906 /* ObservationDetails.swift in Sources */, @@ -1309,6 +1338,7 @@ 1F43DE232EC6137F00B6F07B /* GarminConnectViewModel.swift in Sources */, B78B4A7E29F0420700A1BA58 /* ObservationDetailsViewModel.swift in Sources */, 07EF6C4129B5E0C700CEF37D /* PermissionManager.swift in Sources */, + 5AEA0DB1FAD1484C9BA099EF /* HealthKitManager.swift in Sources */, 1F8EA2CD2A0BDF9A00F32602 /* LimeSurveyViewModel.swift in Sources */, EDBB2B4429B8B2A100CA973E /* Int64Extension.swift in Sources */, 1F34797129B8AFE10030CA15 /* IOSObservationFactory.swift in Sources */, diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 6b60f855..22757607 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -50,7 +50,9 @@ class AppDelegate: NSObject, UIApplicationDelegate { observationFactory: IOSObservationFactory(repository: repositories, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: 30, - connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: isDebug + connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: isDebug, + pollingTaskScheduler: IOSPollingTaskScheduler() + ) }() @@ -71,6 +73,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { DataUploadBackgroundTask.setupBackgroundTasks() DailyBackgroundTask.setupBackgroundTasks() ObservationReminderBackgroundTask.setupBackgroundTasks() + PollingBackgroundTask.setupBackgroundTasks() let routes = Set(NavigationScreen.allCases.map { $0.values.navigationLink.route }) diff --git a/iosApp/iosApp/BackgroundTasks/IOSPollingTaskScheduler.swift b/iosApp/iosApp/BackgroundTasks/IOSPollingTaskScheduler.swift new file mode 100644 index 00000000..c3e42d73 --- /dev/null +++ b/iosApp/iosApp/BackgroundTasks/IOSPollingTaskScheduler.swift @@ -0,0 +1,13 @@ +import Foundation +import shared + +/// Bridges the shared `PollingTaskScheduler` contract to `PollingBackgroundTask`'s BGAppRefreshTask. +class IOSPollingTaskScheduler: PollingTaskScheduler { + func schedule(intervalMillis: Int64) { + PollingBackgroundTask.schedule(interval: TimeInterval(intervalMillis) / 1000) + } + + func cancel() { + PollingBackgroundTask.cancel() + } +} diff --git a/iosApp/iosApp/BackgroundTasks/PollingBackgroundTask.swift b/iosApp/iosApp/BackgroundTasks/PollingBackgroundTask.swift new file mode 100644 index 00000000..4f22ed98 --- /dev/null +++ b/iosApp/iosApp/BackgroundTasks/PollingBackgroundTask.swift @@ -0,0 +1,70 @@ +import Foundation +import BackgroundTasks +import shared + +// Single shared poll task for every currently activated ManualObserver observation (e.g. Health +// Connect) - see PollingObservationRegistry in shared code, which submits/cancels this task only +// when the set of activated observation types actually changes (never redundantly). +// NOTE: Add the identifier below to Info.plist under BGTaskSchedulerPermittedIdentifiers. +enum PollingBackgroundTask { + static let taskID = AppDelegate.bundleId + ".observation-polling" + + private static var currentInterval: TimeInterval? + + static func setupBackgroundTasks() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskID, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + handle(task: refreshTask) + } + } + + static func schedule(interval: TimeInterval) { + currentInterval = interval + let request = BGAppRefreshTaskRequest(identifier: taskID) + request.earliestBeginDate = Date(timeIntervalSinceNow: interval) + do { + try BGTaskScheduler.shared.submit(request) + Napier.i("PollingBackgroundTask::schedule - scheduled for \(request.earliestBeginDate ?? Date()), interval \(interval)s") + } catch { + Napier.e("PollingBackgroundTask::schedule - failed to schedule: \(error)") + } + } + + static func cancel() { + currentInterval = nil + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskID) + } + + private static func handle(task: BGAppRefreshTask) { + // Resubmitting here is the platform mechanic that keeps a one-shot BGAppRefreshTask firing + // repeatedly while polling is active - unrelated to (and not in conflict with) + // PollingObservationRegistry never redundantly resubmitting on activate()/deactivate(). + if let interval = currentInterval { + schedule(interval: interval) + } + + var finished = false + task.expirationHandler = { + if !finished { + Napier.w("PollingBackgroundTask expired before completion") + task.setTaskCompleted(success: false) + } + } + + Task { @MainActor in + do { + try await AppDelegate.shared.observationFactory.pollActiveObservations() + finished = true + Napier.i("PollingBackgroundTask completed successfully") + task.setTaskCompleted(success: true) + } catch { + finished = true + Napier.e("PollingBackgroundTask failed: \(error)") + task.setTaskCompleted(success: false) + } + } + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 5b091040..1f83c41b 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -7,6 +7,7 @@ $(PRODUCT_BUNDLE_IDENTIFIER).data-upload $(PRODUCT_BUNDLE_IDENTIFIER).dailyRefresh $(PRODUCT_BUNDLE_IDENTIFIER).observation-reminder-refresh + $(PRODUCT_BUNDLE_IDENTIFIER).observation-polling CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -54,7 +55,10 @@ devices NSCameraUsageDescription - $(PRODUCT_NAME) needs your camera to scan QR-Code + More needs your camera to scan QR-Code + NSHealthShareUsageDescription + More needs access to your Health data (e.g. heart rate, steps and distance) to record it for certain studies + NSLocationAlwaysAndWhenInUseUsageDescription $(PRODUCT_NAME) needs to access you location to track your position for certain studies diff --git a/iosApp/iosApp/InfoPlist.xcstrings b/iosApp/iosApp/InfoPlist.xcstrings index fd63a8a9..678e6a93 100644 --- a/iosApp/iosApp/InfoPlist.xcstrings +++ b/iosApp/iosApp/InfoPlist.xcstrings @@ -64,19 +64,31 @@ } } }, + "NSHealthShareUsageDescription" : { + "comment" : "Privacy - Health Share Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "MORE needs access to your Health data (e.g. heart rate, steps and distance) to record it for certain studies\n " + } + } + } + }, "NSLocationAlwaysAndWhenInUseUsageDescription" : { "extractionState" : "manual", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "$(PRODUCT_NAME) benötigt Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." + "value" : "MORE benötigt Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "$(PRODUCT_NAME) needs to access you location to track your position for certain studies" + "value" : "MORE needs to access you location to track your position for certain studies" } } } @@ -87,13 +99,13 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "$(PRODUCT_NAME) benötigt jederzeit Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." + "value" : "MORE benötigt jederzeit Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "$(PRODUCT_NAME) always needs access to you location to track your position for certain studies" + "value" : "MORE always needs access to you location to track your position for certain studies" } } } @@ -104,13 +116,13 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "$(PRODUCT_NAME) benötigt Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." + "value" : "MORE benötigt Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "$(PRODUCT_NAME) needs to access you location to track your position for certain studies" + "value" : "MORE needs to access you location to track your position for certain studies" } } } @@ -121,13 +133,13 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "$(PRODUCT_NAME) benötigt während der Nutzung Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." + "value" : "MORE benötigt während der Nutzung Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "$(PRODUCT_NAME) needs access to your location to track your position for certain studies" + "value" : "MORE needs access to your location to track your position for certain studies" } } } @@ -138,13 +150,13 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "$(PRODUCT_NAME) benötigt Zugriff auf deine Bewegungsdaten, um deine Aktivität auch im Hintergrund aufzuzeichnen." + "value" : "MORE benötigt Zugriff auf deine Bewegungsdaten, um deine Aktivität auch im Hintergrund aufzuzeichnen." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "$(PRODUCT_NAME) needs access to your motion data to record your movement data in the background" + "value" : "MORE needs access to your motion data to record your movement data in the background" } } } @@ -155,13 +167,13 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "$(PRODUCT_NAME) möchte deine App-Nutzung für deine Studie erfassen." + "value" : "MORE möchte deine App-Nutzung für deine Studie erfassen." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "$(PRODUCT_NAME) wants to track your app usage for your study" + "value" : "MORE wants to track your app usage for your study" } } } diff --git a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift deleted file mode 100644 index 6f4f62fa..00000000 --- a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// BackgroundSensorRecorder.swift -// iosApp -// -// Created by Jan Cortiel on 20.03.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// 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 -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import CoreMotion -import Foundation -import UIKit -import shared - -class AccelerometerBackgroundObservation: Observation_ { - private var recordForDurationInSec: Double = 60 * 10 - private let recorder = CMSensorRecorder() - private var startRecording: Date = Date() - - private var timer: Timer? - private let semaphore = Semaphore() - private let observationRepository: ObservationRepository - - init(repos: MainRepository, sensorPermissions: Set) { - observationRepository = repos.observation - super.init(repos: repos, observationType: AccelerometerType(sensorPermissions: sensorPermissions)) - } - - override func start() -> Bool { - if observerAccessible() { - recorder.recordAccelerometer(forDuration: recordForDurationInSec) - startRecording = Date() - print("CMSensorRecorder started recording accelerometer data for the next \(recordForDurationInSec)s...") - DispatchQueue.main.async { [weak self] in - self?.timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] timer in - if let self { - self.collectData(start: Date(timeIntervalSince1970: TimeInterval(self.lastCollectionTimestamp.epochSeconds)), end: Date()) { - if self.startRecording.timeIntervalSince1970 + self.recordForDurationInSec <= Date().timeIntervalSince1970 { - timer.invalidate() - } - } - } else { - timer.invalidate() - } - } - } - - return true - } - return false - } - - override func stop(onCompletion: @escaping () -> Void) { - timer?.invalidate() - collectData(start: Date(timeIntervalSince1970: TimeInterval(lastCollectionTimestamp.epochSeconds)), end: Date(), completion: onCompletion) - } - - override func store(start: Int64, end: Int64, onCompletion: @escaping () -> Void) { - collectData(start: Date(timeIntervalSince1970: TimeInterval(start)), end: Date(timeIntervalSince1970: TimeInterval(end))) { - print("\(Date()): Data collected") - super.store(start: start, end: end, onCompletion: {}) - print("\(Date()): Returning from store function") - onCompletion() - } - } - - override func applyObservationConfig(settings: [String: Any]) { - if var start = settings[Observation_.companion.CONFIG_TASK_START] as? Int64, - let end = settings[Observation_.companion.CONFIG_TASK_STOP] as? Int64, - Date(timeIntervalSince1970: TimeInterval(end)) > Date() - { - let startDate = Date(timeIntervalSince1970: TimeInterval(start)) - let endDate = Date(timeIntervalSince1970: TimeInterval(end)) - if startDate < Date() { - start = Int64(Date().timeIntervalSince1970) - } - print("Recording time from \(startDate) to \(endDate); \(Double(end - start))s") - recordForDurationInSec = Double(end - start) - } - } - - override func observerErrors() -> Set { - var errors: Set = [] - if !CMSensorRecorder.isAccelerometerRecordingAvailable() { - errors.insert("Accelerometer Recording is not available") - } - if CMSensorRecorder.authorizationStatus() != .authorized { - errors.insert("Permission not granted to access Sensor recording service") - PermissionManager.openSensorPermissionDialog() - } - return errors - } -} - -extension AccelerometerBackgroundObservation: ObservationCollector { - func collectData(start: Date, end: Date, completion: @escaping () -> Void) { - if start < end { - DispatchQueue.global(qos: .background).async { [weak self] in - if let self { - if let sensorData = self.recorder.accelerometerData(from: start, to: end) { - self.collectionTimestampToNow() - let data = sensorData.enumerated().compactMap { index, data -> ObservationBulkModel? in - if index % 2 == 0, let accDatum = data as? CMRecordedAccelerometerData { - let accel = accDatum.acceleration - let timestamp = accDatum.startDate.timeIntervalSince1970 - let dict = ["x": accel.x, "y": accel.y, "z": accel.z] - return ObservationBulkModel(data: dict, timestamp: Int64(timestamp)) - } - return nil - } - self.storeData(data: data) { - completion() - } - } else { - completion() - } - } - } - } else { - print("Start must be smaller than end! Start: \(start); End: \(end)") - completion() - } - } -} diff --git a/iosApp/iosApp/Observations/AccelerometerRecorderCollector.swift b/iosApp/iosApp/Observations/AccelerometerRecorderCollector.swift new file mode 100644 index 00000000..07308052 --- /dev/null +++ b/iosApp/iosApp/Observations/AccelerometerRecorderCollector.swift @@ -0,0 +1,60 @@ +// +// AccelerometerRecorderCollector.swift +// iosApp +// +// Created by Jan Cortiel on 20.03.23. +// Copyright © 2023 Ludwig Boltzmann Institute for +// 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 +// (see https://www.apache.org/licenses/LICENSE-2.0 and +// https://commonsclause.com/). +// + +import CoreMotion +import Foundation +import shared + +/// Platform integration for `BackgroundAccelerometerObservation` (shared Kotlin) - wraps +/// `CMSensorRecorder`, which keeps buffering accelerometer samples on-device while the app is +/// suspended or killed. Recording/reading are otherwise independent of this app's lifecycle; +/// the shared observation decides when to arm and drain the recorder. +final class AccelerometerRecorderCollector: BackgroundAccelerometerCollector { + private let recorder = CMSensorRecorder() + + var isRecordingAvailable: Bool { + CMSensorRecorder.isAccelerometerRecordingAvailable() + } + + func record(durationSeconds: Double) { + recorder.recordAccelerometer(forDuration: durationSeconds) + Napier.d("CMSensorRecorder started recording accelerometer data for the next \(durationSeconds)s...") + } + + func collect(from: KotlinInstant, to: KotlinInstant) async throws -> [ObservationBulkModel] { + let start = Date(timeIntervalSince1970: TimeInterval(from.epochSeconds)) + let end = Date(timeIntervalSince1970: TimeInterval(to.epochSeconds)) + guard start < end else { + Napier.w("AccelerometerRecorderCollector::collect - start must be smaller than end! Start: \(start); End: \(end)") + return [] + } + + guard let sensorData = recorder.accelerometerData(from: start, to: end) else { + return [] + } + return sensorData.enumerated().compactMap { index, data -> ObservationBulkModel? in + guard index % 2 == 0, let accDatum = data as? CMRecordedAccelerometerData else { + return nil + } + let accel = accDatum.acceleration + let dict = ["x": accel.x, "y": accel.y, "z": accel.z] + return ObservationBulkModel( + data: dict, + timestamp: Int64(accDatum.startDate.timeIntervalSince1970), + instanceId: nil + ) + } + } +} diff --git a/iosApp/iosApp/Observations/HealthObservationCollectors/HeartRateHealthConnectCollector.swift b/iosApp/iosApp/Observations/HealthObservationCollectors/HeartRateHealthConnectCollector.swift new file mode 100644 index 00000000..9cb47ebb --- /dev/null +++ b/iosApp/iosApp/Observations/HealthObservationCollectors/HeartRateHealthConnectCollector.swift @@ -0,0 +1,57 @@ +// +// HeartRateHealthConnectCollector.swift +// iosApp +// +// Licensed under the Apache 2.0 license with Commons Clause +// (see https://www.apache.org/licenses/LICENSE-2.0 and +// https://commonsclause.com/). +// + +import Foundation +import HealthKit +import shared + +class HeartRateHealthConnectCollector: HealthConnectCollector { + let permissionGroup: String = ConstantsKt.HEALTH_COLLECTOR_GROUP + + private let manager = HealthKitManager.shared + + var dataType: HealthConnectDataType { .heartRate } + var permissionKey: String { dataType.subTypeValue } + + func permissionState() async throws -> PermissionApprovalState { + manager.permissionState(for: .heartRate) + } + + func requestPermission() async throws { + try await manager.requestPermissions(for: HealthKitManager.metrics(for: dataType)) + } + + func collect(from: KotlinInstant, to: KotlinInstant) async throws -> [HealthConnectSample] { + let samples = try await manager.samples( + for: .heartRate, + from: Date(timeIntervalSince1970: TimeInterval(from.epochSeconds)), + to: Date(timeIntervalSince1970: TimeInterval(to.epochSeconds)) + ) + return samples.map { sample in + let bpm = Int32(sample.quantity.doubleValue(for: HealthKitManager.Metric.heartRate.unit).rounded()) + let timestamp = KotlinInstant.companion.fromEpochMilliseconds( + epochMilliseconds: Int64(sample.startDate.timeIntervalSince1970 * 1000) + ) + return HealthConnectSample.HeartRate( + timestamp: timestamp, + bpm: bpm, + device: sample.device?.name ?? sample.device?.model, + sourceApp: sample.sourceRevision.source.bundleIdentifier + ) + } + } + + func collectDistanceInMeters(from: KotlinInstant, to: KotlinInstant) async throws -> KotlinDouble? { + nil + } + + func hasUnrequestedBonusPermission() async throws -> KotlinBoolean { + KotlinBoolean(bool: false) + } +} diff --git a/iosApp/iosApp/Observations/HealthObservationCollectors/StepsHealthConnectCollector.swift b/iosApp/iosApp/Observations/HealthObservationCollectors/StepsHealthConnectCollector.swift new file mode 100644 index 00000000..24af886c --- /dev/null +++ b/iosApp/iosApp/Observations/HealthObservationCollectors/StepsHealthConnectCollector.swift @@ -0,0 +1,81 @@ +// +// StepsHealthConnectCollector.swift +// iosApp +// +// Licensed under the Apache 2.0 license with Commons Clause +// (see https://www.apache.org/licenses/LICENSE-2.0 and +// https://commonsclause.com/). +// + +import Foundation +import HealthKit +import shared + +final class StepsHealthConnectCollector: HealthConnectCollector { + let permissionGroup: String = ConstantsKt.HEALTH_COLLECTOR_GROUP + + private let manager = HealthKitManager.shared + + let dataType: HealthConnectDataType = .steps + + var permissionKey: String { dataType.subTypeValue } + + func permissionState() async throws -> PermissionApprovalState { + manager.permissionState(for: .steps) + } + + func requestPermission() async throws { + // Requested together so distance (a "bonus" field on the daily aggregate, see + // `collectDistanceInMeters`) is covered by the same system prompt as steps - distance + // being denied must not block steps collection, so it is not checked in permissionState(). + try await manager.requestPermissions(for: HealthKitManager.metrics(for: dataType)) + } + + func hasUnrequestedBonusPermission() async throws -> KotlinBoolean { + KotlinBoolean(bool: manager.permissionState(for: .distanceWalkingRunning) == .notSet) + } + + func collect( + from: KotlinInstant, + to: KotlinInstant + ) async throws -> [HealthConnectSample] { + let samples = try await manager.samples( + for: .steps, + from: Date(timeIntervalSince1970: TimeInterval(from.epochSeconds)), + to: Date(timeIntervalSince1970: TimeInterval(to.epochSeconds)) + ) + return samples.map { sample in + let count = Int64(sample.quantity.doubleValue(for: HealthKitManager.Metric.steps.unit).rounded()) + let start = KotlinInstant.companion.fromEpochMilliseconds( + epochMilliseconds: Int64(sample.startDate.timeIntervalSince1970 * 1000) + ) + let end = KotlinInstant.companion.fromEpochMilliseconds( + epochMilliseconds: Int64(sample.endDate.timeIntervalSince1970 * 1000) + ) + return HealthConnectSample.Steps( + timestamp: end, + count: count, + start: start, + end: end, + device: sample.device?.name ?? sample.device?.model, + sourceApp: sample.sourceRevision.source.bundleIdentifier, + stepsGoal: nil, + distanceInMeters: nil + ) + } + } + + func collectDistanceInMeters(from: KotlinInstant, to: KotlinInstant) async throws -> KotlinDouble? { + let samples = try await manager.samples( + for: .distanceWalkingRunning, + from: Date(timeIntervalSince1970: TimeInterval(from.epochSeconds)), + to: Date(timeIntervalSince1970: TimeInterval(to.epochSeconds)) + ) + Napier.d("Received distance samples: \(samples.count)") + guard !samples.isEmpty else { return nil } + let total = samples.reduce(0.0) { partial, sample in + partial + sample.quantity.doubleValue(for: HealthKitManager.Metric.distanceWalkingRunning.unit) + } + return KotlinDouble(double: total) + } +} diff --git a/iosApp/iosApp/Observations/IOSObservationFactory.swift b/iosApp/iosApp/Observations/IOSObservationFactory.swift index 948ae53c..61bf9c6d 100644 --- a/iosApp/iosApp/Observations/IOSObservationFactory.swift +++ b/iosApp/iosApp/Observations/IOSObservationFactory.swift @@ -24,7 +24,11 @@ class IOSObservationFactory: ObservationFactory { } registerObservation { - AccelerometerBackgroundObservation(repos: repository, sensorPermissions: ["cmsensorrecorder"]) + BackgroundAccelerometerObservation(repos: repository, sensorPermissions: ["cmsensorrecorder"], collector: AccelerometerRecorderCollector()) + } + + registerObservation { + HealthConnectObservation(repos: repository, observationFactory: self, collectors: [HeartRateHealthConnectCollector(), StepsHealthConnectCollector()]) } registerObservation { diff --git a/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift b/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift index 0cdf6a6d..68db5445 100644 --- a/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift +++ b/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift @@ -140,6 +140,75 @@ class IOSObservationPermissionObserver: NSObject, ObservationPermissionObserver PermissionManager.openSensorPermissionDialog() AppDelegate.shared.observationFactory.stopRequestingPermissions() } + + func permissionStates(collectors: Any) async throws -> [String: PermissionApprovalState] { + if let permissionCollectors = collectors as? [PermissionCollector] { + return try await permissionStates(collectors: permissionCollectors) + } + return [:] + } + + func permissionStates(collectors: [PermissionCollector]) async throws -> [String: PermissionApprovalState] { + if collectors.isEmpty { + return [:] + } + + var pairs: [(String, PermissionApprovalState)] = [] + pairs.reserveCapacity(collectors.count) + + try await withThrowingTaskGroup(of: (String, PermissionApprovalState).self) { group in + for collector in collectors { + group.addTask { + let state = try await collector.permissionState() + return (collector.permissionKey, state) + } + } + + for try await pair in group { + pairs.append(pair) + } + } + + return Dictionary(uniqueKeysWithValues: pairs) + } + + + func requestPermissions(collectors: Any) async throws { + if let permissionCollectors = collectors as? [PermissionCollector] { + try await requestPermissions(collectors: permissionCollectors) + } + } + + + @MainActor func requestPermissions(collectors: [PermissionCollector]) async throws { + guard !collectors.isEmpty else { return } + AppDelegate.shared.observationFactory.startRequestingPermissions() + defer { + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } + + let bundled = collectors.compactMap { $0 as? BundledPermissionCollector } + let nonBundled = collectors.filter { !($0 is BundledPermissionCollector) } + + let grouped = Dictionary(grouping: bundled, by: { $0.permissionGroup }) + for (group, groupCollectors) in grouped { + if group == ConstantsKt.HEALTH_COLLECTOR_GROUP { + let healthCollectors = groupCollectors.compactMap { $0 as? HealthConnectCollector } + let metrics = Set(healthCollectors.flatMap { HealthKitManager.metrics(for: $0.dataType) }) + if !metrics.isEmpty { + try await HealthKitManager.shared.requestPermissions(for: metrics) + } + } else { + for collector in groupCollectors { + try await collector.requestPermission() + } + } + } + + for collector in nonBundled { + try await collector.requestPermission() + } + } } extension IOSObservationPermissionObserver: CLLocationManagerDelegate { diff --git a/iosApp/iosApp/Services/HealthKitManager.swift b/iosApp/iosApp/Services/HealthKitManager.swift new file mode 100644 index 00000000..e9327110 --- /dev/null +++ b/iosApp/iosApp/Services/HealthKitManager.swift @@ -0,0 +1,113 @@ +// +// HealthKitManager.swift +// iosApp +// +// Licensed under the Apache 2.0 license with Commons Clause +// (see https://www.apache.org/licenses/LICENSE-2.0 and +// https://commonsclause.com/). +// + +import Foundation +import HealthKit +import shared + +/// Singleton bridging Apple HealthKit to the shared `HealthConnectCollector` contract. The +/// consent/permission flow requests every needed metric in one system prompt via +/// `requestPermissions(for:)`; `permissionState(for:)`/`requestPermission(for:)` allow +/// checking/requesting a single metric, e.g. when a collector is registered later on. +final class HealthKitManager { + static let shared = HealthKitManager() + + /// One case per Health Connect subtype backed by HealthKit; mirrors `HealthConnectDataType`. + /// Adding a new metric only needs a new case here plus its `quantityTypeIdentifier`/`unit`. + enum Metric: CaseIterable { + case heartRate + case steps + case distanceWalkingRunning + + var quantityTypeIdentifier: HKQuantityTypeIdentifier { + switch self { + case .heartRate: return .heartRate + case .steps: return .stepCount + case .distanceWalkingRunning: return .distanceWalkingRunning + } + } + + var quantityType: HKQuantityType { + HKQuantityType.quantityType(forIdentifier: quantityTypeIdentifier)! + } + + var unit: HKUnit { + switch self { + case .heartRate: return HKUnit.count().unitDivided(by: .minute()) + case .steps: return .count() + case .distanceWalkingRunning: return .meter() + } + } + } + + private let healthStore = HKHealthStore() + + private init() {} + + /// Every HealthKit metric a Health Connect subtype needs, including bonus fields requested in + /// the same system prompt (steps also reads distance for the daily aggregate). Single source + /// of truth so the permission-request path and each collector's own `requestPermission()` + /// cannot diverge. + static func metrics(for dataType: HealthConnectDataType) -> Set { + switch dataType { + case .heartRate: return [.heartRate] + case .steps: return [.steps, .distanceWalkingRunning] + default: return [] + } + } + + var isHealthDataAvailable: Bool { HKHealthStore.isHealthDataAvailable() } + + /// HealthKit never reveals whether a *read-only* request was actually granted or denied + /// (`authorizationStatus` only ever returns `.notDetermined` or `.sharingDenied` for read + /// types, by design, to keep apps from inferring the presence of health data). So the only + /// signal available here is whether the user has been asked at all; once asked, treat it as + /// usable and let `samples(for:)` come back empty if access was actually denied. + func permissionState(for metric: Metric) -> PermissionApprovalState { + guard isHealthDataAvailable else { return .declined } + switch healthStore.authorizationStatus(for: metric.quantityType) { + case .notDetermined: return .notSet + default: return .granted + } + } + + /// Requests read access for a single metric. + func requestPermission(for metric: Metric) async throws { + try await requestPermissions(for: [metric]) + } + + /// Requests read access for a set of metrics in one system prompt. + func requestPermissions(for metrics: Set) async throws { + guard isHealthDataAvailable, !metrics.isEmpty else { return } + let types = Set(metrics.map(\.quantityType)) as Set + try await healthStore.requestAuthorization(toShare: [], read: types) + } + + /// Generic poll entry point: any registered metric's samples within `[from, to)`. Every + /// `HealthConnectCollector.collect` on iOS delegates to this and transforms the result. + func samples(for metric: Metric, from: Date, to: Date) async throws -> [HKQuantitySample] { + guard isHealthDataAvailable else { return [] } + let predicate = HKQuery.predicateForSamples(withStart: from, end: to, options: .strictStartDate) + return try await withCheckedThrowingContinuation { continuation in + let query = HKSampleQuery( + sampleType: metric.quantityType, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)] + ) { _, samples, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: (samples as? [HKQuantitySample]) ?? []) + } + } + healthStore.execute(query) + } + } +} diff --git a/iosApp/iosApp/Style/MoreColor.swift b/iosApp/iosApp/Style/MoreColor.swift deleted file mode 100644 index 85725a63..00000000 --- a/iosApp/iosApp/Style/MoreColor.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// ColorExtension.swift -// iosApp -// -// Created by Jan Cortiel on 03.02.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// 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 -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import SwiftUI - -extension Color { - static let more = Color.MoreColor() - - struct MoreColor { - let primaryDark = Color("PrimaryDark") - let primary = Color("Primary") - let primaryMedium = Color("PrimaryMedium") - let primaryLight200 = Color("PrimaryLight200") - let primaryLight = Color("PrimaryLight") - - let secondary = Color("Secondary") - let secondaryMedium = Color("SecondaryMedium") - let secondaryLight = Color("SecondaryLight") - - let textDefault = Color("Secondary") - let textInactive = Color("SecondaryMedium") - - let important = Color("Important") - let importantMedium = Color("ImportantMedium") - let importantLight = Color("ImportantLight") - - let approved = Color("Approved") - let approvedMedium = Color("ApprovedMedium") - let approvedLight = Color("ApprovedLight") - - let white = Color("White") - - // special elements - let divider = Color("PrimaryLight") - let mainBackground = Color("SecondaryLight") - } -} diff --git a/iosApp/iosApp/Views/Components/CheckboxField.swift b/iosApp/iosApp/Views/Components/CheckboxField.swift index ca2df6d3..cf8e2e1b 100644 --- a/iosApp/iosApp/Views/Components/CheckboxField.swift +++ b/iosApp/iosApp/Views/Components/CheckboxField.swift @@ -32,7 +32,7 @@ struct CheckboxField: View { Spacer() }.foregroundColor(.more.primaryLight) } - .foregroundColor(.more.white) + .foregroundColor(.white) .padding(.bottom, 7) .buttonStyle(.plain) .contentShape(Rectangle()) diff --git a/iosApp/iosApp/Views/Consent/ConsentView.swift b/iosApp/iosApp/Views/Consent/ConsentView.swift index 1aaa8239..039867cf 100644 --- a/iosApp/iosApp/Views/Consent/ConsentView.swift +++ b/iosApp/iosApp/Views/Consent/ConsentView.swift @@ -93,7 +93,9 @@ struct ConsentView: View { mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), - reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true + reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true, + pollingTaskScheduler: nil + ) let registration = RegistrationObservable(service: RegistrationService(shared: shared)) ConsentView(registration: registration) diff --git a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift index ccd723cd..fbd1ece3 100644 --- a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift +++ b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift @@ -81,6 +81,13 @@ class ConsentViewModel: ObservableObject { extension ConsentViewModel: PermissionManagerObserver { func accepted() { Task { @MainActor in + // Delegates to HealthConnectObservation's own collector-aware permission check (the + // same logic already used at schedule-start time) instead of a hardcoded HealthKit + // request here - it scopes to whichever subtypes the study actually needs, requests + // only what's missing, and shows its own alert on decline. + try? await AppDelegate.shared.observationFactory + .observation(type: ObservationTypeEnum.healthConnect.value)? + .updateObservationPermissions() if permissionManager.anyNeededPermissionDeclined() { AlertController.shared.openAlertDialog( model: diff --git a/iosApp/iosApp/Views/Login/LoginButton.swift b/iosApp/iosApp/Views/Login/LoginButton.swift index d6ad9f2e..cc3983f0 100644 --- a/iosApp/iosApp/Views/Login/LoginButton.swift +++ b/iosApp/iosApp/Views/Login/LoginButton.swift @@ -21,7 +21,7 @@ struct LoginButton: View { let action: () -> Void var body: some View { - MoreActionButton(backgroundColor: Color.more.primary, disabled: .constant(disabled)) { + MoreActionButton(backgroundColor: Color.pc.primary, disabled: .constant(disabled)) { action() } label: { Text("login_button") diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index 977914ce..35b88f86 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -152,7 +152,9 @@ struct LoginView: View { mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), - reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true + reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true, + pollingTaskScheduler: nil + ) let registration = RegistrationObservable(service: RegistrationService(shared: shared)) LoginView(registration: registration) diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index c09a50d5..6a3e493e 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -125,8 +125,11 @@ struct ScanQRCodeView: View { mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), + networkWatcher: nil, reminderNotificationSchedulingLimit: nil, - connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true + connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true, + pollingTaskScheduler: nil + ) let registrationService = RegistrationService(shared: sharedContainer) ScanQRCodeView(model: LoginViewModel(registration: registrationService)) diff --git a/iosApp/iosApp/Views/ObservationErrorListView.swift b/iosApp/iosApp/Views/ObservationErrorListView.swift index a8bfa45c..c5f589bf 100644 --- a/iosApp/iosApp/Views/ObservationErrorListView.swift +++ b/iosApp/iosApp/Views/ObservationErrorListView.swift @@ -25,7 +25,7 @@ struct ObservationErrorListView: View { HStack { Image(systemName: "exclamationmark.triangle") .font(.more.headline) - .foregroundColor(.more.important) + .foregroundColor(.pc.failure) .padding(.trailing, 4) BasicText(text: "\(error)!") } diff --git a/iosApp/iosApp/iosApp.entitlements b/iosApp/iosApp/iosApp.entitlements index 57c50cc4..e810fcf5 100644 --- a/iosApp/iosApp/iosApp.entitlements +++ b/iosApp/iosApp/iosApp.entitlements @@ -4,6 +4,8 @@ aps-environment development + com.apple.developer.healthkit + com.apple.developer.kernel.extended-virtual-addressing com.apple.developer.kernel.increased-memory-limit diff --git a/openapi/HealthTransformationAPI.yaml b/openapi/HealthTransformationAPI.yaml new file mode 100644 index 00000000..12958c12 --- /dev/null +++ b/openapi/HealthTransformationAPI.yaml @@ -0,0 +1,526 @@ +openapi: 3.0.3 + +info: + title: Health Transformation Model + description: | + Provider-independent models used to normalize health data before it is + transformed into DataPoints. + + These models are intended to provide a common representation for health + data originating from different providers such as Garmin Connect, + Apple Health and Google Health Connect. + + Provider-specific integrations are responsible for converting their + native data structures into these models. The normalized data can then + be transformed into the existing DataPoint representation. + + The existing Garmin transformation models remain in place for backwards + compatibility with the Garmin ingestion pipeline. + version: "1" + +paths: { } + +components: + schemas: + + # ------------------------------------------------------------------------- + # Common temporal wrapper + # ------------------------------------------------------------------------- + + TimeData: + type: object + description: | + A timestamped health measurement. + + `timestamp` represents the effective timestamp of the resulting + DataPoint. + + `startTime` and `endTime` may additionally be provided for data that + describes an interval rather than a single measurement. + + `additionalData` can be used for metadata which should be retained + during transformation but does not belong to the normalized health + measurement itself. + properties: + timestamp: + type: string + format: date-time + description: Effective timestamp of the measurement. + + startTime: + type: string + format: date-time + description: Start of the represented time interval, if applicable. + + endTime: + type: string + format: date-time + description: End of the represented time interval, if applicable. + + device: + type: string + description: The device identifier, from where the data were recorded on. + + data: + oneOf: + - $ref: "#/components/schemas/HeartRateData" + - $ref: "#/components/schemas/StepData" + - $ref: "#/components/schemas/ActivityData" + - $ref: "#/components/schemas/BloodPressureData" + - $ref: "#/components/schemas/SleepData" + description: Normalized health measurement. + + additionalData: + type: object + additionalProperties: true + default: { } + description: | + Optional additional metadata associated with this measurement. + + Provider-specific metadata may be placed here when it cannot be + represented by the common model. Consumers must not rely on such + metadata being available for every provider. + + required: + - timestamp + - data + + + # ------------------------------------------------------------------------- + # Heart rate + # ------------------------------------------------------------------------- + + HeartRateData: + type: object + description: | + A single heart-rate measurement independent of the source provider. + properties: + hr: + type: integer + format: int32 + minimum: 0 + description: Heart rate in beats per minute (BPM). + required: + - hr + + + # ------------------------------------------------------------------------- + # Steps / distance + # ------------------------------------------------------------------------- + + StepData: + type: object + description: | + Step and distance information for a point or interval in time. + properties: + steps: + type: integer + format: int32 + minimum: 0 + description: Number of steps. + + stepsGoal: + type: integer + format: int32 + minimum: 0 + description: | + Step goal associated with the measurement, if provided by the + source. + + Not all providers expose a step goal. + + distanceInMeters: + type: number + format: double + minimum: 0 + description: Distance travelled in meters. + + + # ------------------------------------------------------------------------- + # Activity + # ------------------------------------------------------------------------- + + ActivityData: + type: object + description: | + Normalized activity information. + + Some properties are optional because not all health providers expose + motion intensity, MET or active-duration information. + properties: + activityType: + $ref: "#/components/schemas/ActivityType" + + met: + type: number + format: double + minimum: 0 + description: | + Metabolic equivalent of task (MET), if available. + + intensity: + oneOf: + - $ref: "#/components/schemas/ActivityIntensity" + + activeTimeInSeconds: + type: integer + format: int64 + minimum: 0 + description: Duration considered active, in seconds. + + meanMotionIntensity: + type: number + format: double + description: | + Mean motion intensity when provided by the source. + + maxMotionIntensity: + type: number + format: double + description: | + Maximum motion intensity when provided by the source. + + required: + - activityType + + + ActivityType: + type: string + description: | + Provider-independent activity classification. + + Native provider activity values should be mapped to the closest + semantically equivalent value. Values that cannot safely be mapped + should use OTHER rather than inventing an equivalence. + enum: + - WALKING + - RUNNING + - WHEELCHAIR_PUSHING + - SEDENTARY + - SLEEP + - OTHER + + + ActivityIntensity: + type: string + description: Normalized activity intensity. + enum: + - SEDENTARY + - ACTIVE + - HIGHLY_ACTIVE + + + # ------------------------------------------------------------------------- + # Blood pressure + # ------------------------------------------------------------------------- + + BloodPressureData: + type: object + description: | + A blood-pressure measurement independent of the source provider. + properties: + systolic: + type: integer + format: int32 + description: Systolic blood pressure in mmHg. + + diastolic: + type: integer + format: int32 + description: Diastolic blood pressure in mmHg. + + pulse: + type: integer + format: int32 + minimum: 0 + description: | + Pulse rate in beats per minute at the time of the reading, + if supplied with the blood-pressure measurement. + + sourceType: + $ref: "#/components/schemas/MeasurementSourceType" + + required: + - systolic + - diastolic + + + MeasurementSourceType: + type: string + description: | + How the measurement was created. + + This replaces the Garmin-specific interpretation of source type with + a provider-independent representation. + enum: + - MANUAL + - DEVICE + - APPLICATION + - UNKNOWN + + + # ------------------------------------------------------------------------- + # Sleep + # ------------------------------------------------------------------------- + + SleepData: + type: object + description: | + Normalized sleep information. + + Raw measurements and sleep stages should be mapped where a semantic + equivalent exists. Provider-specific derived metrics such as sleep + scores should only be provided when they actually exist and must not + be synthesized simply to match another provider. + properties: + calendarDate: + type: string + format: date + description: Local calendar date associated with the sleep period. + + totalNapDurationInSeconds: + type: integer + format: int64 + minimum: 0 + + unmeasurableSleepInSeconds: + type: integer + format: int64 + minimum: 0 + description: | + Duration of sleep that could not be assigned to a specific stage. + + deepSleepDurationInSeconds: + type: integer + format: int64 + minimum: 0 + + lightSleepDurationInSeconds: + type: integer + format: int64 + minimum: 0 + + remSleepDurationInSeconds: + type: integer + format: int64 + minimum: 0 + + awakeDurationInSeconds: + type: integer + format: int64 + minimum: 0 + + validation: + type: string + description: | + Validation or classification information reported by the original + provider. + + The value is intentionally not constrained to Garmin validation + values because other health providers may use different + classifications. + + spo2Samples: + type: array + default: [ ] + items: + $ref: "#/components/schemas/OxygenSaturationData" + description: Timestamped oxygen-saturation measurements during sleep. + + sleepLevels: + type: array + default: [ ] + items: + $ref: "#/components/schemas/SleepLevelSegment" + description: | + Normalized sleep-stage intervals. + + sleepScore: + $ref: "#/components/schemas/SleepScore" + description: | + Overall sleep score when supplied by the source provider. + + Scores from different providers must not be assumed to be + directly comparable. + + sleepScores: + $ref: "#/components/schemas/SleepScoreBreakdown" + description: | + Optional detailed sleep-score breakdown when provided by the + source. + + naps: + type: array + default: [ ] + items: + $ref: "#/components/schemas/NapData" + + + # ------------------------------------------------------------------------- + # Sleep levels + # ------------------------------------------------------------------------- + + SleepLevelSegment: + type: object + description: | + A continuous interval belonging to a normalized sleep stage. + properties: + sleepLevel: + $ref: "#/components/schemas/SleepLevel" + + startTime: + type: string + format: date-time + + endTime: + type: string + format: date-time + + required: + - sleepLevel + - startTime + - endTime + + + SleepLevel: + type: string + description: | + Provider-independent sleep stage. + + Provider-specific stages should only be mapped where the semantics are + sufficiently equivalent. + enum: + - AWAKE + - LIGHT + - DEEP + - REM + - ASLEEP + - IN_BED + - UNKNOWN + + + # ------------------------------------------------------------------------- + # SpO2 + # ------------------------------------------------------------------------- + + OxygenSaturationData: + type: object + description: A timestamped oxygen-saturation measurement. + properties: + timestamp: + type: string + format: date-time + + percentage: + type: number + format: double + minimum: 0 + maximum: 100 + description: Oxygen saturation as percentage. + + required: + - timestamp + - percentage + + + # ------------------------------------------------------------------------- + # Naps + # ------------------------------------------------------------------------- + + NapData: + type: object + description: Information about an individual nap. + properties: + startTime: + type: string + format: date-time + + endTime: + type: string + format: date-time + + durationInSeconds: + type: integer + format: int64 + minimum: 0 + + validation: + type: string + description: | + Optional validation/classification information from the source + provider. + + required: + - startTime + + + # ------------------------------------------------------------------------- + # Provider-derived sleep scores + # ------------------------------------------------------------------------- + + SleepScore: + type: object + description: | + A sleep score reported by the source provider. + + The meaning and calculation of a sleep score may differ between + providers. + properties: + value: + type: integer + + qualifier: + type: string + description: Human-readable/provider-defined score qualification. + + source: + $ref: "#/components/schemas/HealthDataSource" + + required: + - value + + + SleepScoreBreakdown: + type: object + description: | + Optional provider-derived components contributing to a sleep score. + properties: + totalDuration: + $ref: "#/components/schemas/SleepScoreQualifier" + + stress: + $ref: "#/components/schemas/SleepScoreQualifier" + + awakeCount: + $ref: "#/components/schemas/SleepScoreQualifier" + + remPercentage: + $ref: "#/components/schemas/SleepScoreQualifier" + + restlessness: + $ref: "#/components/schemas/SleepScoreQualifier" + + lightPercentage: + $ref: "#/components/schemas/SleepScoreQualifier" + + deepPercentage: + $ref: "#/components/schemas/SleepScoreQualifier" + + + SleepScoreQualifier: + type: object + properties: + qualifier: + type: string + + + # ------------------------------------------------------------------------- + # Provenance + # ------------------------------------------------------------------------- + + HealthDataSource: + type: string + description: Health-data provider from which the measurement originated. + enum: + - DEVICE + - MANUAL \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 526943c5..cf966182 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,6 +8,8 @@ pluginManagement { plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } +// Note: settings.gradle.kts plugin blocks resolve before the version catalog is available, +// so this one keeps a hardcoded version rather than using libs.plugins.foojay.resolver.convention. dependencyResolutionManagement { repositories { diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 02881cd6..1f4e986e 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -14,15 +14,15 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.openapitools.generator.gradle.plugin.tasks.GenerateTask plugins { - kotlin("multiplatform") - kotlin("plugin.serialization") - - id("com.android.library") - id("androidx.room") - id("com.google.devtools.ksp") - id("com.rickclephas.kmp.nativecoroutines") - id("org.openapi.generator").version("7.17.0").apply(true) - id("dev.icerock.mobile.multiplatform-resources") + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) + + alias(libs.plugins.android.library) + alias(libs.plugins.room) + alias(libs.plugins.ksp) + alias(libs.plugins.kmp.nativecoroutines) + alias(libs.plugins.openapi.generator) + alias(libs.plugins.moko.resources) } val generated = "$rootDir/shared/build/generated" @@ -93,6 +93,7 @@ kotlin { implementation("androidx.security:security-crypto-ktx:1.1.0") implementation("io.ktor:ktor-client-android:$ktorVersion") implementation("com.google.code.gson:gson:$gsonVersion") + implementation("androidx.core:core-ktx:1.13.1") } iosMain.dependencies { diff --git a/shared/src/androidMain/kotlin/io/redlink/more/Platform.kt b/shared/src/androidMain/kotlin/io/redlink/more/Platform.kt index 42ec2646..e4d602cb 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/Platform.kt +++ b/shared/src/androidMain/kotlin/io/redlink/more/Platform.kt @@ -14,5 +14,5 @@ import android.os.Build actual fun getPlatform(): Platform = Platform( name = "Android ${Build.VERSION.SDK_INT}", - productName = Build.PRODUCT + productName = Build.PRODUCT ?: "Android" ) \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/io/redlink/more/events/AndroidDayMonitor.kt b/shared/src/androidMain/kotlin/io/redlink/more/events/AndroidDayMonitor.kt new file mode 100644 index 00000000..74fffd3f --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/events/AndroidDayMonitor.kt @@ -0,0 +1,115 @@ +package io.redlink.more.events + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import io.github.aakira.napier.Napier +import io.redlink.more.extensions.today +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlin.time.Clock + +private lateinit var applicationContext: Context + +fun initPlatformContext(context: Context) { + applicationContext = context.applicationContext +} + +actual class DayMonitor actual constructor( + private val onEvent: (AppEvent) -> Unit, +) { + private var registered = false + + private var lastKnownDate = LocalDate.today() + private var lastKnownTimeZone = TimeZone.currentSystemDefault() + + private val receiver = object : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent? + ) { + when (intent?.action) { + Intent.ACTION_DATE_CHANGED -> { + updateDate() + } + + Intent.ACTION_TIME_CHANGED -> { + onEvent( + AppEvent.SystemTimeChanged( + Clock.System.now() + ) + ) + refresh() + } + + Intent.ACTION_TIMEZONE_CHANGED -> { + refresh() + } + } + } + } + + actual fun start() { + if (registered) { + refresh() + return + } + + val filter = IntentFilter().apply { + addAction(Intent.ACTION_DATE_CHANGED) + addAction(Intent.ACTION_TIME_CHANGED) + addAction(Intent.ACTION_TIMEZONE_CHANGED) + } + + ContextCompat.registerReceiver( + applicationContext, + receiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + + registered = true + refresh() + } + + actual fun refresh() { + updateTimeZone() + updateDate() + } + + private fun updateDate() { + val currentDate = LocalDate.today() + + if (currentDate != lastKnownDate) { + lastKnownDate = currentDate + onEvent(AppEvent.DayChanged(currentDate)) + } + } + + private fun updateTimeZone() { + val currentTimeZone = TimeZone.currentSystemDefault() + + if (currentTimeZone != lastKnownTimeZone) { + lastKnownTimeZone = currentTimeZone + onEvent(AppEvent.TimeZoneChanged(currentTimeZone)) + } + } + + actual fun stop() { + if (!registered) { + return + } + + try { + applicationContext.unregisterReceiver(receiver) + } catch (e: Exception) { + Napier.e(e) { + "Exception during DayMonitor receiver unregistration" + } + } finally { + registered = false + } + } +} diff --git a/shared/src/androidMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectStrings.kt b/shared/src/androidMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectStrings.kt new file mode 100644 index 00000000..e32cf9fc --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectStrings.kt @@ -0,0 +1,12 @@ +package io.redlink.more.observations.healthConnect + +import dev.icerock.moko.resources.StringResource +import io.redlink.more.SharedRes + +actual object HealthConnectStrings { + actual val providerTypeString: StringResource = SharedRes.strings.type_health_connect_android + actual val providerShortTypeString: StringResource = + SharedRes.strings.type_health_connect_android_short + actual val heartRateTypeString: StringResource = SharedRes.strings.type_health_connect_heart_rate + actual val stepsTypeString: StringResource = SharedRes.strings.type_health_connect_steps +} diff --git a/shared/src/androidMain/kotlin/io/redlink/more/services/network/AndroidNetworkWatcher.kt b/shared/src/androidMain/kotlin/io/redlink/more/services/network/AndroidNetworkWatcher.kt new file mode 100644 index 00000000..db0fab6a --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/services/network/AndroidNetworkWatcher.kt @@ -0,0 +1,67 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ +package io.redlink.more.services.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +class AndroidNetworkWatcher(private val context: Context) : NetworkWatcher { + override fun watchNetworkState(): Flow = callbackFlow { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + fun updateState() { + val activeNetwork = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + val isConnected = + capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + trySend(isConnected) + } + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(false) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + val isConnected = + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + trySend(isConnected) + } + } + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback(request, callback) + + updateState() + + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } + }.distinctUntilChanged() +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/Constants.kt b/shared/src/commonMain/kotlin/io/redlink/more/Constants.kt new file mode 100644 index 00000000..1bd6d673 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/Constants.kt @@ -0,0 +1,6 @@ +package io.redlink.more + +const val HEALTH_CONNECT_PREFIX = "health-connect" + +const val HEALTH_COLLECTOR_GROUP = "health-collector" + diff --git a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt index 22c3b193..5ad723b4 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt @@ -15,7 +15,11 @@ import dev.icerock.moko.resources.desc.Resource import dev.icerock.moko.resources.desc.StringDesc import dev.tmapps.konnection.Konnection import io.github.aakira.napier.Napier +import io.redlink.more.database.entities.NotificationEntity import io.redlink.more.database.repository.MainRepository +import io.redlink.more.events.AppEvent +import io.redlink.more.events.DayMonitor +import io.redlink.more.events.EventBus import io.redlink.more.extensions.toStudyState import io.redlink.more.logging.EventCollection import io.redlink.more.logging.EventObserver @@ -28,6 +32,8 @@ import io.redlink.more.observations.ObservationFactory import io.redlink.more.observations.ObservationManager import io.redlink.more.observations.ObservationStates import io.redlink.more.observations.observationTypes.GarminType +import io.redlink.more.observations.polling.PollingObservationRegistry +import io.redlink.more.observations.polling.PollingTaskScheduler import io.redlink.more.scopes.Scope import io.redlink.more.scopes.StudyScope import io.redlink.more.services.ObservationService @@ -35,6 +41,7 @@ import io.redlink.more.services.bluetooth.BluetoothConnector import io.redlink.more.services.network.NetworkService import io.redlink.more.services.network.NetworkServiceImpl import io.redlink.more.services.network.NetworkServiceProxy +import io.redlink.more.services.network.NetworkWatcher import io.redlink.more.services.network.demo.DemoNetworkService import io.redlink.more.services.network.openapi.model.Study import io.redlink.more.services.notification.LocalNotificationListener @@ -72,6 +79,8 @@ open class Shared( mainBluetoothConnector: BluetoothConnector, val observationFactory: ObservationFactory, val dataRecorder: DataRecorder, + networkWatcher: NetworkWatcher? = null, + pollingTaskScheduler: PollingTaskScheduler? = null, reminderNotificationSchedulingLimit: Int? = null, val connectionStatusFlow: Flow = konnectionInstance().observeHasConnection(), @@ -115,10 +124,24 @@ open class Shared( val observationService = ObservationService(repositories, notificationManager, reminderNotificationSchedulingLimit) + private val dayMonitor = DayMonitor { event -> + EventBus.tryPublish(event) + } + private val mutex = Mutex() private var mainJob: Job? = null init { + PollingObservationRegistry.init(pollingTaskScheduler, sharedStorageRepository) + + networkWatcher?.let { watcher -> + Scope.launch { + watcher.watchNetworkState().distinctUntilChanged().collect { + ViewManager.networkConnected(it) + } + } + } + observationFactory.observationsWithInterface(EventObserver::class) .forEach { EventCollection.addObserver(it) } val handler = CoroutineExceptionHandler { _, t -> @@ -163,6 +186,25 @@ open class Shared( } } }.second + + Scope.launch { + combine( + credentialRepository.hasCredentials, + repositories.study.studyState, + ViewManager.appInForeground + ) { hasCredentials, studyState, appInForeground -> + hasCredentials && studyState.isActive() && appInForeground + } + .distinctUntilChanged() + .collect { shouldMonitor -> + if (shouldMonitor) { + dayMonitor.start() + dayMonitor.refresh() + } else { + dayMonitor.stop() + } + } + } } fun updateData(appInForeground: Boolean) { @@ -201,6 +243,7 @@ open class Shared( observationManager.updateTaskStates() observationService.scheduleObservationReminder() notificationManager.downloadMissedNotifications() + EventBus.tryPublish(AppEvent.ScheduleHaveUpdated) } override fun updateStudy( @@ -354,6 +397,7 @@ open class Shared( repositories.study.upsert(s) } updateSchedules() + EventBus.publish(AppEvent.StudyHasUpdated) } catch (e: Exception) { Napier.e(tag = "Shared::updateStudy") { "Exception during updating study: $e" } if (repositories.study.study.value == null) { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt index 63f54302..1f3240b6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt @@ -17,6 +17,7 @@ import androidx.room.RoomDatabase import io.redlink.more.database.dao.AggregatedObservationDataDao import io.redlink.more.database.dao.BluetoothDeviceDao import io.redlink.more.database.dao.DataPointDao +import io.redlink.more.database.dao.LatestObservationDataDao import io.redlink.more.database.dao.NotificationDao import io.redlink.more.database.dao.ObservationDao import io.redlink.more.database.dao.ObservationDataDao @@ -25,6 +26,7 @@ import io.redlink.more.database.dao.StudyDao import io.redlink.more.database.entities.AggregatedObservationDataEntity import io.redlink.more.database.entities.BluetoothDeviceEntity import io.redlink.more.database.entities.DataPointEntity +import io.redlink.more.database.entities.LatestObservationDataEntity import io.redlink.more.database.entities.NotificationEntity import io.redlink.more.database.entities.ObservationDataEntity import io.redlink.more.database.entities.ObservationEntity @@ -40,9 +42,10 @@ import io.redlink.more.database.entities.StudyEntity NotificationEntity::class, BluetoothDeviceEntity::class, DataPointEntity::class, - AggregatedObservationDataEntity::class + AggregatedObservationDataEntity::class, + LatestObservationDataEntity::class ], - version = 3 + version = 4 ) @ConstructedBy(AppDatabaseConstructor::class) abstract class AppDatabase : RoomDatabase() { @@ -54,4 +57,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun bluetoothDeviceDao(): BluetoothDeviceDao abstract fun dataPointDao(): DataPointDao abstract fun aggregatedObservationDataDao(): AggregatedObservationDataDao + abstract fun latestObservationDataDao(): LatestObservationDataDao } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt index c09ffe75..de769dad 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt @@ -15,6 +15,7 @@ import androidx.room.RoomDatabaseConstructor import androidx.sqlite.driver.bundled.BundledSQLiteDriver import io.redlink.more.database.migrations.MIGRATION_1_2 import io.redlink.more.database.migrations.MIGRATION_2_3 +import io.redlink.more.database.migrations.MIGRATION_3_4 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -27,5 +28,5 @@ fun getRoomDatabase(builder: RoomDatabase.Builder): AppDatabase = builder .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3) - .build() \ No newline at end of file + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) + .build() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/LatestObservationDataDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/LatestObservationDataDao.kt new file mode 100644 index 00000000..b52ace6f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/LatestObservationDataDao.kt @@ -0,0 +1,37 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ +package io.redlink.more.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.redlink.more.database.entities.LatestObservationDataEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface LatestObservationDataDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(data: LatestObservationDataEntity) + + @Query("SELECT * FROM latest_observation_data WHERE scheduleId = :scheduleId") + fun getByScheduleId(scheduleId: String): Flow + + @Query("SELECT * FROM latest_observation_data WHERE observationType = :observationType ORDER BY timestamp DESC LIMIT 1") + suspend fun getLatestByObservationType(observationType: String): LatestObservationDataEntity? + + @Query("DELETE FROM latest_observation_data WHERE scheduleId = :scheduleId") + suspend fun deleteByScheduleId(scheduleId: String) + + @Query("DELETE FROM latest_observation_data") + suspend fun deleteAll() +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/LatestObservationDataEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/LatestObservationDataEntity.kt new file mode 100644 index 00000000..eedd387c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/LatestObservationDataEntity.kt @@ -0,0 +1,31 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ +package io.redlink.more.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlin.time.Clock + +/** + * Keeps exactly one row per schedule holding the most recently recorded data point for that + * schedule's observation - unlike [ObservationDataEntity] (full history), this exists only for + * "current value" visualizations (e.g. today list items), analogous to how [GoalDataEntity] + * resolves its latest value per schedule instance via `scheduleTimestamp`, but without keeping + * history: every new data point simply replaces the previous row for that scheduleId. + */ +@Entity(tableName = "latest_observation_data") +data class LatestObservationDataEntity( + @PrimaryKey val scheduleId: String, + val observationId: String = "", + val observationType: String = "", + val dataValue: String = "", + val timestamp: Long = Clock.System.now().toEpochMilliseconds() +) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_3_4.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_3_4.kt new file mode 100644 index 00000000..6fb717fd --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_3_4.kt @@ -0,0 +1,33 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ + +package io.redlink.more.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL( + """ + CREATE TABLE IF NOT EXISTS `latest_observation_data` ( + `scheduleId` TEXT NOT NULL, + `observationId` TEXT NOT NULL, + `observationType` TEXT NOT NULL, + `dataValue` TEXT NOT NULL, + `timestamp` INTEGER NOT NULL, + PRIMARY KEY(`scheduleId`) + ) + """.trimIndent() + ) + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepository.kt index c3e4efad..0806fabb 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepository.kt @@ -11,6 +11,7 @@ package io.redlink.more.database.repository import io.ktor.utils.io.core.Closeable +import io.redlink.more.database.entities.LatestObservationDataEntity import io.redlink.more.database.entities.ObservationEntity import io.redlink.more.database.entities.ScheduleEntity import kotlinx.coroutines.flow.Flow @@ -44,4 +45,10 @@ interface ObservationRepository { fun observationById(observationId: String): Flow suspend fun getObservationByObservationId(observationId: String): ObservationEntity? -} \ No newline at end of file + + suspend fun storeLatestDataPoint(data: LatestObservationDataEntity) + + fun latestDataPointForSchedule(scheduleId: String): Flow + + suspend fun latestDataPointTimestamp(observationType: String): Long? +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepositoryImpl.kt index 1aa0be4d..5901e411 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepositoryImpl.kt @@ -12,6 +12,7 @@ package io.redlink.more.database.repository import io.ktor.utils.io.core.Closeable import io.redlink.more.database.AppDatabase +import io.redlink.more.database.entities.LatestObservationDataEntity import io.redlink.more.database.entities.ObservationEntity import io.redlink.more.database.entities.ScheduleEntity import io.redlink.more.extensions.asClosure @@ -28,28 +29,29 @@ class ObservationRepositoryImpl(private val appDatabase: AppDatabase) : Observat appDatabase.observationDao().getAllFlow() override fun observationWithUndoneSchedules(): Flow>> { - return appDatabase.scheduleDao().getByDoneFlow(false) + return appDatabase.scheduleDao().getAllFlow() .combine(observations()) { schedules: List, observations: List -> observations.associateWith { observation -> - schedules.filter { schedule -> schedule.observationId == observation.observationId } + schedules.filter { schedule -> + schedule.observationId == observation.observationId && !schedule.getState() + .completed() + } } } } override suspend fun updateLastCollection(type: String, timestamp: Long) { - val observations = appDatabase.observationDao().getByObservationType(type) - observations.forEach { observation -> - val updatedObservation = observation.copy(collectionTimestamp = timestamp) - appDatabase.observationDao().update(updatedObservation) - } + updateLastCollection(setOf(type), timestamp) } override suspend fun updateLastCollection(types: Set, timestamp: Long) { types.forEach { type -> val observations = appDatabase.observationDao().getByObservationType(type) observations.forEach { observation -> - val updatedObservation = observation.copy(collectionTimestamp = timestamp) - appDatabase.observationDao().update(updatedObservation) + if (observation.collectionTimestamp != timestamp) { + val updatedObservation = observation.copy(collectionTimestamp = timestamp) + appDatabase.observationDao().update(updatedObservation) + } } } } @@ -75,17 +77,15 @@ class ObservationRepositoryImpl(private val appDatabase: AppDatabase) : Observat emit(maxTimestamp) } - override fun collectTimestampOfType(type: String, newState: (Long?) -> Unit): Closeable { - return collectionTimestamp(type).asClosure(newState) - } + override fun collectTimestampOfType(type: String, newState: (Long?) -> Unit): Closeable = + collectionTimestamp(type).asClosure(newState) override fun collectAllTimestamps(newState: (Map) -> Unit): Closeable { return collectAllTimestamps().asClosure(newState) } - override fun collectObservationsWithUndoneSchedules(newState: (Map>) -> Unit): Closeable { - return observationWithUndoneSchedules().asClosure(newState) - } + override fun collectObservationsWithUndoneSchedules(newState: (Map>) -> Unit): Closeable = + observationWithUndoneSchedules().asClosure(newState) override fun observationTypes(): Flow> = observations().transform { observationList -> @@ -98,4 +98,15 @@ class ObservationRepositoryImpl(private val appDatabase: AppDatabase) : Observat override suspend fun getObservationByObservationId(observationId: String): ObservationEntity? { return appDatabase.observationDao().getByObservationId(observationId) } + + override suspend fun storeLatestDataPoint(data: LatestObservationDataEntity) { + appDatabase.latestObservationDataDao().upsert(data) + } + + override fun latestDataPointForSchedule(scheduleId: String): Flow = + appDatabase.latestObservationDataDao().getByScheduleId(scheduleId) + + override suspend fun latestDataPointTimestamp(observationType: String): Long? = + appDatabase.latestObservationDataDao() + .getLatestByObservationType(observationType)?.timestamp } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/events/DayChangeMonitor.kt b/shared/src/commonMain/kotlin/io/redlink/more/events/DayChangeMonitor.kt new file mode 100644 index 00000000..5e9352d0 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/events/DayChangeMonitor.kt @@ -0,0 +1,9 @@ +package io.redlink.more.events + +expect class DayMonitor( + onEvent: (AppEvent) -> Unit +) { + fun start() + fun stop() + fun refresh() +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/events/EventBus.kt b/shared/src/commonMain/kotlin/io/redlink/more/events/EventBus.kt new file mode 100644 index 00000000..45683e16 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/events/EventBus.kt @@ -0,0 +1,59 @@ +package io.redlink.more.events + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlin.time.Instant + + +sealed interface AppEvent { + data object ScheduleHaveUpdated : AppEvent + + data object StudyHasUpdated : AppEvent + + data object DeviceBootComplete : AppEvent + + data class DataRefresh( + val type: String, + val observationId: String, + val scheduleId: String? = null + ) : AppEvent + + data class DayChanged( + val date: LocalDate + ) : AppEvent + + data class TimeZoneChanged( + val timeZone: TimeZone + ) : AppEvent + + data class SystemTimeChanged( + val instant: Instant + ) : AppEvent +} + +object EventBus { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _events = MutableSharedFlow( + extraBufferCapacity = 64 + ) + + val events = _events.asSharedFlow() + + inline fun eventsOf(): Flow = events.filterIsInstance() + + fun publish(event: AppEvent) { + scope.launch { + _events.emit(event) + } + } + + fun tryPublish(event: AppEvent): Boolean = _events.tryEmit(event) +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/events/TimedEventBus.kt b/shared/src/commonMain/kotlin/io/redlink/more/events/TimedEventBus.kt new file mode 100644 index 00000000..daef793b --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/events/TimedEventBus.kt @@ -0,0 +1,130 @@ +package io.redlink.more.events + +import io.github.aakira.napier.Napier +import io.redlink.more.util.alignedNowFlow +import io.redlink.more.util.createUUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +object TimedEventBus { + private val tickDuration = 1.minutes + + private data class Subscription( + val interval: Duration, + var lastBucket: Long, + val onTick: suspend () -> Unit + ) + + private val scope = + CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val mutex = Mutex() + + private val subscriptions = + mutableMapOf() + + private var tickerJob: Job? = null + + fun subscribe( + interval: Duration = 1.minutes, + onTick: suspend () -> Unit + ): String { + require(interval.isPositive()) + + val id = createUUID() + + scope.launch { + mutex.withLock { + subscriptions[id] = Subscription( + interval = interval, + lastBucket = currentBucket(interval), + onTick = onTick + ) + } + + ensureTickerStarted() + } + + return id + } + + fun unsubscribe(id: String) { + scope.launch { + val shouldStopTicker = mutex.withLock { + subscriptions.remove(id) + subscriptions.isEmpty() + } + + if (shouldStopTicker) { + tickerJob?.cancel() + tickerJob = null + } + } + } + + private fun currentTimestamp(): Long { + return Clock.System.now() + .toEpochMilliseconds() + } + + private fun currentBucket( + interval: Duration + ): Long { + return currentTimestamp() / + interval.inWholeMilliseconds + } + + private fun ensureTickerStarted() { + if (tickerJob?.isActive == true) { + return + } + + tickerJob = scope.launch { + alignedNowFlow( + periodMs = tickDuration.inWholeMilliseconds + ).collect { + tick() + } + } + } + + private suspend fun tick() { + val now = currentTimestamp() + + val due = mutex.withLock { + subscriptions.values + .filter { subscription -> + val bucket = + now / subscription.interval.inWholeMilliseconds + + bucket > subscription.lastBucket + } + .onEach { subscription -> + subscription.lastBucket = + now / subscription.interval.inWholeMilliseconds + } + .toList() + } + + due.forEach { subscription -> + try { + subscription.onTick() + } catch (e: Exception) { + Napier.e( + throwable = e, + tag = "TimedEventBus" + ) { + "Error notifying subscriber" + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/extensions/DateTimeConverter.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/DateTimeConverter.kt index 421ebe96..69b1385d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/extensions/DateTimeConverter.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/DateTimeConverter.kt @@ -17,6 +17,8 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.atTime import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.Clock +import kotlinx.datetime.todayIn fun Instant.fromUTCtoCurrent(): Instant { val currentZone = TimeZone.currentSystemDefault() @@ -25,6 +27,13 @@ fun Instant.fromUTCtoCurrent(): Instant { fun Instant.localDateTime(): LocalDateTime = this.toLocalDateTime(TimeZone.currentSystemDefault()) +fun Instant.localDate(): LocalDate = this.localDateTime().date + +fun kotlin.time.Instant.localDate(): LocalDate = + Instant.fromEpochMilliseconds(this.toEpochMilliseconds()).localDate() + +fun LocalDate.Companion.today(): LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()) + fun LocalDate.time(): Long = this.atTime(0, 0).toInstant(TimeZone.currentSystemDefault()).epochSeconds diff --git a/shared/src/commonMain/kotlin/io/redlink/more/extensions/JsonExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/JsonExtension.kt new file mode 100644 index 00000000..1027289b --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/JsonExtension.kt @@ -0,0 +1,40 @@ +package io.redlink.more.extensions + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +@PublishedApi +internal val sharedJson: Json = Json { + ignoreUnknownKeys = true + isLenient = true +} + +inline fun String.jsonRead(): T? = + runCatching { sharedJson.decodeFromString(this) } + .recoverCatching { sharedJson.parseToJsonElement(this).toAny() as T } + .getOrNull() + +inline fun T.jsonString(): String = + runCatching { sharedJson.encodeToString(this) }.getOrDefault("{}") + +fun JsonElement.toAny(): Any? = when (this) { + is JsonNull -> null + is JsonPrimitive -> { + if (isString) { + content + } else { + booleanOrNull ?: longOrNull ?: doubleOrNull ?: content + } + } + + is JsonObject -> mapValues { it.value.toAny() } + is JsonArray -> map { it.toAny() } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/extensions/ScheduleEntityExtenstion.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/ScheduleEntityExtenstion.kt index 616d0352..9eb21772 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/extensions/ScheduleEntityExtenstion.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/ScheduleEntityExtenstion.kt @@ -4,6 +4,8 @@ import io.redlink.more.database.entities.NotificationEntity import io.redlink.more.database.entities.ScheduleEntity import io.redlink.more.models.NotificationTextKey +fun ScheduleEntity.reminderId() = "reminder_$scheduleId" + fun ScheduleEntity.toNotificationEntity( userFacing: Boolean, deepLink: String? = null diff --git a/shared/src/commonMain/kotlin/io/redlink/more/extensions/StringResourceExtensions.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/StringResourceExtensions.kt new file mode 100644 index 00000000..1513557d --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/StringResourceExtensions.kt @@ -0,0 +1,10 @@ +package io.redlink.more.extensions + +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.desc.Resource +import dev.icerock.moko.resources.desc.ResourceFormatted +import dev.icerock.moko.resources.desc.StringDesc + +fun StringResource.desc(): StringDesc = StringDesc.Resource(this) + +fun StringResource.formatted(list: List): StringDesc = StringDesc.ResourceFormatted(this, list) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/formatter/HealthConnectValueFormatter.kt b/shared/src/commonMain/kotlin/io/redlink/more/formatter/HealthConnectValueFormatter.kt new file mode 100644 index 00000000..950b1b3f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/formatter/HealthConnectValueFormatter.kt @@ -0,0 +1,44 @@ +package io.redlink.more.formatter + +import dev.icerock.moko.resources.desc.desc +import io.redlink.more.extensions.jsonRead +import io.redlink.more.extensions.localDate +import io.redlink.more.models.DataDisplayValue +import io.redlink.more.observations.healthConnect.HealthConnectDataType +import kotlin.time.Instant + +/** + * Formats the raw JSON payload stored by [io.redlink.more.observations.healthConnect.HealthConnectObservation] + * (e.g. `{"timestamp":...,"hr":72}`, `{"timestamp":...,"steps":1000,"stepsGoal":10000}`) into a + * [DataDisplayValue], keyed by [HealthConnectDataType]. + */ +class HealthConnectValueFormatter { + companion object { + fun format(observationType: String, currentValue: Any?): DataDisplayValue? { + val type = HealthConnectDataType.fromObservationType(observationType) ?: return null + val payload = (currentValue as? String)?.jsonRead>() ?: return null + val data = payload["data"] as? Map ?: return null + val timestamp = (payload["timestamp"] as? String) + ?.let { runCatching { Instant.parse(it).localDate() }.getOrNull() } + return when (type) { + HealthConnectDataType.STEPS -> { + val steps = data[type.valueKey]?.toString() ?: "-" + val goal = data["stepsGoal"]?.toString() + DataDisplayValue( + value = goal?.let { "$steps / $it" } ?: steps, + unit = type.unit.desc(), + label = type.label.desc(), + timestamp = timestamp + ) + } + // composite subtypes (e.g. blood pressure, sleep) get their own branch here + else -> DataDisplayValue( + value = data[type.valueKey]?.toString() ?: "-", + unit = type.unit.desc(), + label = type.label.desc(), + timestamp = timestamp + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/DataDisplayValue.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/DataDisplayValue.kt new file mode 100644 index 00000000..b65510bc --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/DataDisplayValue.kt @@ -0,0 +1,11 @@ +package io.redlink.more.models + +import dev.icerock.moko.resources.desc.StringDesc +import kotlinx.datetime.LocalDate + +data class DataDisplayValue( + val value: String, + val unit: StringDesc? = null, + val label: StringDesc? = null, + val timestamp: LocalDate? = null +) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt index 1132f11c..dacff512 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt @@ -10,37 +10,77 @@ */ package io.redlink.more.observations +import dev.icerock.moko.resources.desc.Raw +import dev.icerock.moko.resources.desc.StringDesc import io.github.aakira.napier.Napier +import io.redlink.more.SharedRes +import io.redlink.more.database.entities.LatestObservationDataEntity import io.redlink.more.database.entities.NotificationEntity import io.redlink.more.database.entities.ObservationDataEntity import io.redlink.more.database.repository.MainRepository +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.extensions.asString +import io.redlink.more.extensions.desc +import io.redlink.more.extensions.formatted +import io.redlink.more.extensions.reminderId import io.redlink.more.models.ScheduleState import io.redlink.more.observations.longRunningObservation.LongRunningObservationStorage import io.redlink.more.observations.observationTypes.ObservationType +import io.redlink.more.observations.polling.PollingObservationRegistry import io.redlink.more.scopes.Scope import io.redlink.more.scopes.StudyScope import io.redlink.more.services.notification.NotificationManager import io.redlink.more.services.store.PermissionApprovalState +import io.redlink.more.util.openSystemSettings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant +import kotlin.time.Clock +import kotlin.time.Instant + +interface Collector +interface PermissionCollector : Collector { + val permissionKey: String + suspend fun permissionState(): PermissionApprovalState + suspend fun requestPermission() +} + +interface BundledPermissionCollector : PermissionCollector { + val permissionGroup: String +} interface ObservationPermissionObserver { fun requestPermission(observationType: ObservationType) fun permissionState(observationType: ObservationType): PermissionApprovalState + + suspend fun permissionStates( + collectors: Collection + ): Map = + collectors.associate { it.permissionKey to it.permissionState() } + + suspend fun requestPermissions( + collectors: Collection + ) { + collectors.forEach { it.requestPermission() } + } } abstract class Observation( protected val repos: MainRepository, val observationType: ObservationType, ) { - private var dataManager: ObservationDataManager? = null + protected val permissionQueryMutex = Mutex() + private val collectorPermissionQueryMutex = Mutex() + private val errorQueryMutex = Mutex() + + protected var dataManager: ObservationDataManager? = null + private set + private var notificationManager: NotificationManager? = null private var permissionObserver: ObservationPermissionObserver? = null @@ -57,7 +97,7 @@ abstract class Observation( private val config = mutableMapOf() private var configChanged = false - protected var lastCollectionTimestamp: Instant = Clock.System.now() + protected var lastCollectionTimestamp: Instant? = null var timestampCollectionJob: Job? = null @@ -70,18 +110,18 @@ abstract class Observation( this.longRunningStorage = longRunningObservationStorage } - open fun start( + open suspend fun start( observationId: String, scheduleId: String, notificationId: String? = null ): Boolean { observationIds.add(observationId) - StudyScope.launch { - val realObservationType = - repos.observation.observationById(observationId).firstOrNull()?.observationType - ?: observationType.observationType - observationTypes[observationId] = realObservationType - } + val realObservationType = + repos.observation.getObservationByObservationId(observationId)?.observationType + ?: repos.observation.observationById(observationId).firstOrNull()?.observationType + ?: observationType.observationType + observationTypes[observationId] = realObservationType + timestampCollectionJob?.cancel() timestampCollectionJob = StudyScope.launch { repos.observation.collectTimestampForObservationIds(observationIds).collect { @@ -108,6 +148,9 @@ abstract class Observation( updateObservationPermissions() } running = start() + if (running) { + activate() + } Napier.i { "Observation with type ${observationType.observationType} started: $running" } running } else true @@ -135,7 +178,7 @@ abstract class Observation( fun observationDataManagerAdded() = dataManager != null - fun setDataManager(observationDataManager: ObservationDataManager) { + fun applyDataManager(observationDataManager: ObservationDataManager) { Napier.i(tag = "Observation::setDataManager") { "Setting data manager for observation of type ${observationType.observationType}." } dataManager = observationDataManager } @@ -181,20 +224,59 @@ abstract class Observation( } } + protected fun observationTypeFor(observationId: String): String? = + observationTypes[observationId] + protected fun collectionTimestampToNow() { Napier.d(tag = "Observation::collectionTimeStampToNow") { "Collecting timestamp" } lastCollectionTimestamp = Clock.System.now() StudyScope.launch(Dispatchers.IO) { - repos.observation.updateLastCollection( - observationIds.toSet(), - lastCollectionTimestamp.toEpochMilliseconds() - ) + lastCollectionTimestamp?.let { + repos.observation.updateLastCollection( + observationIds.toSet(), + it.toEpochMilliseconds() + ) + } } } + /** + * Registers every schedule of this observation type whose window overlaps + * `[lastCollectionTimestamp, now)` - i.e. currently active/running ones plus already-completed + * ones that were still active after the last collection - so [storeData]/[storeInstant] tag + * data with the right observationId/scheduleId. Needed by background poll runs (e.g. triggered + * by a [io.redlink.more.observations.polling.PollingTaskScheduler] task/worker while the app + * wasn't otherwise running) where the normal `start()` lifecycle never populated them. + */ + protected suspend fun registerRecentSchedules(now: Instant = Clock.System.now()) { + getLastCollectionTimestamp()?.let { from -> + val fromEpoch = from.epochSeconds + val nowEpoch = now.epochSeconds + repos.schedule.allSchedulesWithStatus(false) + .firstOrNull().orEmpty() + .filter { schedule -> + observationType.matches(schedule.observationType) && + (schedule.start == null || schedule.start <= nowEpoch) && + (schedule.end == null || schedule.end >= fromEpoch) + } + .forEach { schedule -> + observationIds.add(schedule.observationId) + observationTypes[schedule.observationId] = schedule.observationType + scheduleIds[schedule.scheduleId] = schedule.observationId + } + } + } + + protected suspend fun getLastCollectionTimestamp(): Instant? { + return (lastCollectionTimestamp ?: repos.study.getStudy() + .firstOrNull()?.start?.let { Instant.fromEpochSeconds(it) }) + } + protected abstract fun start(): Boolean - protected abstract fun stop(onCompletion: () -> Unit) + protected open fun stop(onCompletion: () -> Unit) = onCompletion() + + fun observerAccessible(): Boolean { val errors = observerErrors() @@ -204,30 +286,96 @@ abstract class Observation( protected open fun observerErrors(): Set = emptySet() - fun updateObservationPermissions() { - if (hasPermission() != PermissionApprovalState.GRANTED) { - Napier.w { "Permissions not given for observation ${observationType.observationType}! Requesting permissions..." } - requestPermission() - } else { - Napier.d { "All permissions given for observation ${observationType.observationType}!" } - } - } + open suspend fun updateObservationPermissions() = + permissionQueryMutex.withLock { + when (hasPermission()) { + PermissionApprovalState.NOT_SET -> { + Napier.w { + "Permissions not given for observation " + + "${observationType.observationType}! Requesting permissions..." + } + requestPermission() + } - suspend fun updateObservationErrors() { - repos.schedule.allSchedulesToday(observationType).firstOrNull()?.let { - if (it.isNotEmpty()) { - Napier.d(tag = "Observation::updateObservationErrors") { "ObservationErrors for ${observationType.observationType}" } + PermissionApprovalState.DECLINED -> { + Napier.w { + "Permissions declined for observation " + + "${observationType.observationType}! " + + "Showing missing permission alert..." + } + showMissingPermissionAlert() + } - if (repos.study.studyState.value.isActive()) { - ObservationStates.updateObservationErrors( - observationType.observationType, - observerErrors() - ) + PermissionApprovalState.GRANTED -> { + Napier.d { + "All permissions given for observation " + + "${observationType.observationType}!" + } } } } + + protected suspend fun permissionStates( + collectors: Collection + ): Map = + collectorPermissionQueryMutex.withLock { + permissionObserver?.permissionStates(collectors) + ?: collectors.associate { it.permissionKey to it.permissionState() } + } + + protected suspend fun requestPermissions( + collectors: Collection + ) = collectorPermissionQueryMutex.withLock { + permissionObserver?.requestPermissions(collectors) + ?: collectors.forEach { it.requestPermission() } } + /** + * Informs the user that this observation's permission is missing (declined, not just + * unrequested) and lets them jump straight to the system settings to grant it - unlike + * [PermissionApprovalState.NOT_SET], which can be silently re-requested via [requestPermission]'s + * platform prompt. Subclasses sharing one instance across several sub-permissions (e.g. + * [io.redlink.more.observations.healthConnect.HealthConnectObservation]) may pass a more + * specific [titleDesc] naming the affected sub-permission instead of the whole observation type. + */ + protected fun showMissingPermissionAlert( + titleDesc: StringDesc = StringDesc.Raw(observationType.observationType) + ) { + AlertController.openAlertDialog( + AlertDialogModel( + title = SharedRes.strings.observation_permission_missing_title.desc(), + message = SharedRes.strings.observation_permission_missing_message.formatted( + listOf(titleDesc) + ), + confirmLabel = SharedRes.strings.goals_reminder_open_settings.desc(), + cancelLabel = SharedRes.strings.goals_reminder_continue_anyway.desc(), + onConfirm = { openSystemSettings() } + ) + ) + } + + suspend fun updateObservationErrors() = + errorQueryMutex.withLock { + repos.schedule + .allSchedulesToday(observationType) + .firstOrNull() + ?.let { schedules -> + if (schedules.isNotEmpty()) { + Napier.d(tag = "Observation::updateObservationErrors") { + "ObservationErrors for " + + observationType.observationType + } + + if (repos.study.studyState.value.isActive()) { + ObservationStates.updateObservationErrors( + observationType.observationType, + observerErrors() + ) + } + } + } + } + protected abstract fun applyObservationConfig(settings: Map) open fun bleDevicesNeeded(): Set = emptySet() @@ -242,6 +390,10 @@ abstract class Observation( longRunningStorage?.startObservation(data, identifier, timestamp) } + fun upsertLongRunningObservation(data: T, identifier: String, timestamp: Long) { + longRunningStorage?.updateObservation(data, identifier, timestamp) + } + fun finishLongRunningObservation(data: T, identifier: String, timestamp: Long) { longRunningStorage?.finishObservation(data, identifier, timestamp) } @@ -250,6 +402,30 @@ abstract class Observation( longRunningStorage?.inRangeObservation(data, identifier, timestamp) } + /** + * Upserts the single most recent data point for [scheduleId], overwriting whatever was + * stored for that schedule before - unlike [storeData], which appends to the full history, + * this only exists to back "current value" visualizations (e.g. today list items). + */ + protected suspend fun storeLatestDataPoint( + scheduleId: String, + observationId: String, + observationType: String, + data: Any?, + timestamp: Long + ) { + Napier.d { "Storing new datapoint for scheduleId: $scheduleId; observationId: $observationId, type: $observationType, $data" } + repos.observation.storeLatestDataPoint( + LatestObservationDataEntity( + scheduleId = scheduleId, + observationId = observationId, + observationType = observationType, + dataValue = data?.asString() ?: "{}", + timestamp = timestamp + ) + ) + } + fun storeData(data: Map, timestamp: Long = -1, onCompletion: () -> Unit = {}) { val dataSchemas = ObservationDataEntity.fromData( observationIds.toSet(), setOf(ObservationBulkModel(data, timestamp)) @@ -320,14 +496,14 @@ abstract class Observation( } } - fun stopAndSetDone(scheduleId: String) { + open fun stopAndSetDone(scheduleId: String) { Napier.d(tag = "Observation::stopAndSetDone") { "Stopping observation of type ${observationType.observationType} and setting done for schedule $scheduleId." } if (scheduleIds.size <= 1) { stop { timestampCollectionJob?.cancel() saveAndSend() scheduleIds.keys.forEach { - StudyScope.launch(Dispatchers.IO) { + StudyScope.launch(Dispatchers.Default) { repos.schedule.setCompletionStateFor(it, true) } } @@ -340,7 +516,7 @@ abstract class Observation( } } else { saveAndSend() - StudyScope.launch(Dispatchers.IO) { + StudyScope.launch(Dispatchers.Default) { repos.schedule.setCompletionStateFor(scheduleId, true) } observationShutdown(scheduleId) @@ -370,12 +546,21 @@ abstract class Observation( config.clear() configChanged = false running = false + deactivate() } } private fun handleNotification(scheduleId: String) { - notificationIds.remove(scheduleId)?.let { - notificationManager?.markNotificationAsCompleted(it) + Scope.launch { + notificationIds.remove(scheduleId)?.let { + notificationManager?.markNotificationAsCompleted(it) + } ?: run { + repos.schedule.scheduleWithId(scheduleId).firstOrNull()?.let { + if (it.reminder) { + notificationManager?.markNotificationAsCompleted(it.reminderId()) + } + } + } } } @@ -389,31 +574,27 @@ abstract class Observation( notificationBody: String, fallbackTitle: String = "Error" ) { - val schedulesSchemaFlows = scheduleIds.keys.map { - repos.schedule.scheduleWithId(it) - } - val combinedFlow = combine(schedulesSchemaFlows) { values -> - values.mapNotNull { it } - } - StudyScope.launch { - val scheduleSchemas = combinedFlow.first() - val title = - if (scheduleSchemas.isNotEmpty()) scheduleSchemas.joinToString( - ", ", - limit = 5 - ) { it.observationTitle } else fallbackTitle + val observations = scheduleIds.keys + .mapNotNull { repos.schedule.scheduleWithId(it).firstOrNull()?.observationTitle } + .toSet() + val title = if (observations.isNotEmpty()) observations.joinToString( + ", ", + limit = 5 + ) else fallbackTitle withContext(Dispatchers.Main) { showNotification(title, notificationBody) } } } - protected fun saveAndSend() { + open fun saveAndSend() { Napier.d(tag = "Observation::finish") { "Saving and sending data for observation of type ${observationType.observationType}." } - dataManager?.saveAndSend() + dataManager?.store() } + fun isRunning() = running + fun removeDataCount() { Napier.d(tag = "Observation::removeDataCount") { "Removing data point count for observation of type ${observationType.observationType}." } scheduleIds.keys.forEach { @@ -422,7 +603,35 @@ abstract class Observation( scheduleIds.clear() } - open fun onStudyExit() {} + open fun onStudyExit() { + deactivate() + } + + /** + * Activates the shared background poll request (WorkManager `Worker` on Android, + * `BGAppRefreshTask` on iOS) for this observation's [pollIntervalMillis], called once the + * observation is stored and running in an active study. Observations that don't need + * background polling simply leave [pollIntervalMillis] `null`. Safe to call repeatedly - + * [PollingObservationRegistry] never resubmits an already-active request. + */ + open fun activate() { + pollIntervalMillis()?.let { interval -> + PollingObservationRegistry.activate(observationType.observationType, interval) + } + } + + /** + * Deactivates the background poll request started by [activate] - called on study exit, when + * the last schedule referencing this observation is paused, and on observation completion. + */ + open fun deactivate() { + if (pollIntervalMillis() != null) { + PollingObservationRegistry.deactivate(observationType.observationType) + } + } + + /** Ideal background poll interval for this observation, or `null` if it doesn't poll in the background. */ + protected open fun pollIntervalMillis(): Long? = null companion object { private val requestedPermissions = mutableSetOf() @@ -446,5 +655,21 @@ abstract class Observation( const val CONFIG_LAST_COLLECTION_TIMESTAMP = "observation_last_collection_timestamp" const val ERROR_DEVICE_NOT_CONNECTED = "error_device_not_connected" + + /** + * Computes the `[from, to)` collection window, bounded below by the later of the last + * collection timestamp and the task/schedule start, and above by the earlier of now and + * the task/schedule stop. Returns `null` when the window is empty (nothing to collect). + */ + internal fun computeWindow( + lastCollectionTimestamp: Instant, + taskStart: Instant?, + taskStop: Instant?, + now: Instant + ): Pair? { + val from = maxOf(lastCollectionTimestamp, taskStart ?: lastCollectionTimestamp) + val to = taskStop?.let { minOf(it, now) } ?: now + return if (from < to) from to to else null + } } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt index 2e5f4932..d9e51103 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt @@ -17,6 +17,8 @@ import io.redlink.more.logging.EventObserver import io.redlink.more.observations.appUsage.AppUsageObservation import io.redlink.more.observations.garmin.GarminObservation import io.redlink.more.observations.limesurvey.LimeSurveyObservation +import io.redlink.more.observations.observationTypes.ObservationType +import io.redlink.more.observations.observers.ManualObserver import io.redlink.more.observations.questionObservation.QuestionObservation import io.redlink.more.scopes.AppDispatchers import io.redlink.more.scopes.MoreScope @@ -32,9 +34,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield import kotlin.reflect.KClass +import kotlin.time.Duration.Companion.milliseconds abstract class ObservationFactory( repository: MainRepository, @@ -65,12 +69,16 @@ abstract class ObservationFactory( } } } + private var credentialRepository: CredentialRepository? = null open val observations = mutableSetOf() private val _studyObservationTypes: MutableStateFlow> = MutableStateFlow(emptySet()) open val studyObservationTypes: StateFlow> = _studyObservationTypes + private val _observationsFlow = MutableStateFlow>(emptySet()) + val observationsFlow: StateFlow> = _observationsFlow + private val observationProviders = mutableSetOf<() -> Observation>() protected val permissionRepository = PermissionRepositoryImpl( @@ -112,29 +120,54 @@ abstract class ObservationFactory( open fun observationPostConstruct(observation: Observation) {} - protected fun registerObservation(provider: () -> Observation) { + open fun registerObservation(provider: () -> Observation) { observationProviders.add(provider) } private fun initializeNeededObservations(types: Set) { - observationProviders.forEach { provider -> - val observation = provider() - if (observation.observationType.matchesAny(types)) { - if (observations.none { it.observationType.observationType == observation.observationType.observationType }) { - addObservationToList( - observation - ) + if (types.isEmpty()) { + return + } + + val pendingTypes = ArrayDeque(types) + val processedTypes = mutableSetOf() + + while (pendingTypes.isNotEmpty()) { + val currentType = pendingTypes.removeFirst() + if (!processedTypes.add(currentType)) { + continue + } + + observationProviders.forEach { provider -> + val observation = provider() + if (observation.observationType.matches(currentType)) { + val isAlreadyInitialized = observations.any { + it.observationType.observationType == observation.observationType.observationType + } + + if (!isAlreadyInitialized) { + addObservationToList(observation) + } + + observation.observationType.dependentObservationTypes + .filterNot { it in processedTypes } + .forEach { pendingTypes.addLast(it) } } } } + Napier.d("Initialized needed observations: ${observations.map { it.observationType.observationType }}") } private fun addObservationToList(observation: Observation) { observations.add( observation - .also { observationPostConstruct(it) } + .also { + observationPostConstruct(it) + it.applyDataManager(dataManager) + } ) + _observationsFlow.value = observations.toSet() } open fun addNeededObservationTypes(observationTypes: Set) { @@ -154,6 +187,18 @@ abstract class ObservationFactory( ObservationStates.resetAll() } + fun currentObservationTypes(type: String): ObservationType? { + return observations.map { it.observationType }.firstOrNull { it.matches(type) } + } + + fun autoEmit(type: String) = observationsFlow.mapNotNull { obsSet -> + obsSet.firstOrNull { it.observationType.matches(type) } as? T + } + + fun autoEmit(clazz: KClass) = observationsFlow.mapNotNull { obsSet -> + obsSet.firstOrNull { clazz.isInstance(it) } as? T + } + open fun setCredentialsRepository(credentialRepository: CredentialRepository) { this.credentialRepository = credentialRepository } @@ -174,7 +219,7 @@ abstract class ObservationFactory( .map { it.observationType.observationType }.toSet() open fun sensorPermissions() = - observations.map { it.observationType.sensorPermissions }.flatten().toSet() + observations.flatMap { it.observationType.sensorPermissions }.toSet() open fun bleDevicesNeeded(): Set { Napier.i(tag = "ObservationFactory::bleDevicesNeeded") { "Filtering types for BLE: ${studyObservationTypes.value}" } @@ -193,7 +238,7 @@ abstract class ObservationFactory( } private suspend fun updateObservationPermissionsAndErrorsWhenInForeground() { - withTimeoutOrNull(300_000L) { + withTimeoutOrNull(300_000L.milliseconds) { ViewManager.appInForeground.collectLatest { inForeground -> if (inForeground) { Napier.d { "App in foreground, updating observation permissions and errors..." } @@ -205,7 +250,7 @@ abstract class ObservationFactory( var currentLogDelay = 1000L while (true) { Napier.d { "App not in foreground! Waiting for permission check..." } - delay(currentLogDelay) + delay(currentLogDelay.milliseconds) currentLogDelay = (currentLogDelay * 2).coerceAtMost(30000L) } } @@ -246,21 +291,31 @@ abstract class ObservationFactory( it.observationType.matches(type) } ?: observationProviders.map { it() }.firstOrNull { it.observationType.matches(type) } ?.also { - observations.add(it) + addObservationToList(it) } return observation?.apply { if (!this.observationDataManagerAdded()) { Napier.i(tag = "ObservationFactory::observation") { "Adding data manager to observation of type: $type" } - setDataManager(dataManager) + applyDataManager(dataManager) } } } - fun observationsWithInterface(clazz: KClass): Set = + inline fun observationsWithInterface(clazz: KClass): Set = observations.filter { clazz.isInstance(it) } - .mapNotNull { it as? T } + .filterIsInstance() .toSet() + /** + * Runs a single background poll pass, invoked by the platform's shared + * [io.redlink.more.observations.polling.PollingTaskScheduler] task/worker (or, when background + * updates are disabled, on app foregrounding as before) - collects data for every currently + * registered [io.redlink.more.observations.observers.ManualObserver] observation. + */ + suspend fun pollActiveObservations() { + observationsWithInterface(ManualObserver::class).forEach { it.collectAllData() } + } + fun onStudyExit() { observations.forEach { it.onStudyExit() } clearNeededObservationTypes() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt index e3126fbb..155cd4a7 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt @@ -121,7 +121,7 @@ class ObservationManager( } - private fun start( + private suspend fun start( schedule: ScheduleEntity, config: Map ): Boolean { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/accelerometer/BackgroundAccelerometerCollector.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/accelerometer/BackgroundAccelerometerCollector.kt new file mode 100644 index 00000000..19b710be --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/accelerometer/BackgroundAccelerometerCollector.kt @@ -0,0 +1,20 @@ +package io.redlink.more.observations.accelerometer + +import io.redlink.more.observations.Collector +import io.redlink.more.observations.ObservationBulkModel +import kotlin.time.Instant + +/** + * Platform integration point for OS-level background accelerometer recording (iOS: + * `CMSensorRecorder`, which keeps buffering samples on-device while the app is suspended or + * killed; Android has no equivalent, so no Android implementation is expected). + */ +interface BackgroundAccelerometerCollector : Collector { + /** False when the OS cannot record accelerometer data in the background on this device. */ + val isRecordingAvailable: Boolean + + /** Arms the OS-side recorder for the next [durationSeconds]; samples are read back via [collect]. */ + fun record(durationSeconds: Double) + + suspend fun collect(from: Instant, to: Instant): List +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/accelerometer/BackgroundAccelerometerObservation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/accelerometer/BackgroundAccelerometerObservation.kt new file mode 100644 index 00000000..4b031eb6 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/accelerometer/BackgroundAccelerometerObservation.kt @@ -0,0 +1,162 @@ +package io.redlink.more.observations.accelerometer + +import io.github.aakira.napier.Napier +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.events.TimedEventBus +import io.redlink.more.observations.Observation +import io.redlink.more.observations.accelerometer.BackgroundAccelerometerObservation.Companion.DEFAULT_RECORD_DURATION_SECONDS +import io.redlink.more.observations.accelerometer.BackgroundAccelerometerObservation.Companion.MAX_RECORD_DURATION_SECONDS +import io.redlink.more.observations.observationTypes.AccelerometerType +import io.redlink.more.observations.observers.ManualObserver +import io.redlink.more.scopes.Scope +import io.redlink.more.services.store.PermissionApprovalState +import io.redlink.more.viewModels.ViewManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * Shared observation for OS-level background accelerometer recording (iOS `CMSensorRecorder`, + * behind [BackgroundAccelerometerCollector]). Recording itself happens on-device independent of + * this observation's lifecycle; this class arms/re-arms the recorder for the active task window + * and periodically drains recorded samples into the DB - on app foregrounding, on + * [collectAllData] (background poll run, see [io.redlink.more.observations.polling.PollingTaskScheduler]), + * and on [stop]/[store]. + */ +class BackgroundAccelerometerObservation( + repos: MainRepository, + sensorPermissions: Set, + private val collector: BackgroundAccelerometerCollector +) : Observation(repos, AccelerometerType(sensorPermissions)), ManualObserver { + private var taskStart: Instant? = null + private var taskStop: Instant? = null + private var recordDurationSeconds: Double = DEFAULT_RECORD_DURATION_SECONDS + + init { + Scope.launch(Dispatchers.Default) { + TimedEventBus.subscribe { + collectWindow() + } + } + Scope.launch(Dispatchers.Default) { + ViewManager.appInForeground.collect { + if (it) { + collectAllData() + } + } + } + } + + override fun start(): Boolean { + if (observerAccessible()) { + collector.record(recordDurationSeconds) + Scope.launch(Dispatchers.IO) { + delay(5.seconds) + withContext(Dispatchers.Main) { + collectWindow() + } + } + return true + } + return false + } + + override fun stop(onCompletion: () -> Unit) { + Scope.launch(Dispatchers.Default) { + collectWindow() + onCompletion() + } + } + + override fun store(start: Long, end: Long, onCompletion: () -> Unit) { + Scope.launch(Dispatchers.Default) { + collectWindow() + super.store(start, end, onCompletion) + } + } + + override fun applyObservationConfig(settings: Map) { + val start = (settings[CONFIG_TASK_START] as? Long)?.let(Instant::fromEpochSeconds) + val stop = (settings[CONFIG_TASK_STOP] as? Long)?.let(Instant::fromEpochSeconds) + taskStart = start + taskStop = stop + recordDurationSeconds = computeRecordDuration(start, stop, Clock.System.now()) + } + + /** + * Entry point for a background poll run - see [ObservationFactory.pollActiveObservations][io.redlink.more.observations.ObservationFactory.pollActiveObservations]. + * Re-arms the recorder for the remaining task window so recording continues past the + * original window while the app stays in the background. + */ + override suspend fun collectAllData() { + registerRecentSchedules() + collectWindow() + computeReArmDuration(taskStop, Clock.System.now())?.let { collector.record(it) } + } + + override fun observerErrors(): Set { + val errors = mutableSetOf() + if (!collector.isRecordingAvailable) { + errors.add("Accelerometer Recording is not available") + } + if (hasPermission() != PermissionApprovalState.GRANTED) { + errors.add("Permission not granted to access Sensor recording service") + } + return errors + } + + override fun pollIntervalMillis(): Long = POLL_INTERVAL_MILLIS + + private suspend fun collectWindow() { + val lastCollected = getLastCollectionTimestamp() ?: return + val (from, to) = computeWindow(lastCollected, taskStart, taskStop, Clock.System.now()) + ?: return + val data = collector.collect(from, to) + Napier.d { "New Acc data: $data" } + if (data.isNotEmpty()) { + storeData(data) {} + collectionTimestampToNow() + } + Napier.d(tag = "BackgroundAccelerometerObservation") { "Collected ${data.size} accelerometer samples from $from to $to" } + } + + companion object { + private const val POLL_INTERVAL_MILLIS = 15 * 60 * 1000L + private const val DEFAULT_RECORD_DURATION_SECONDS = 60.0 * 10 + private const val MAX_RECORD_DURATION_SECONDS = 60.0 * 60 * 12 + + /** + * Seconds to arm [BackgroundAccelerometerCollector.record] for, derived from the task + * window - falls back to [DEFAULT_RECORD_DURATION_SECONDS] when there is no usable window + * (missing bounds, or already elapsed), and clamps to [MAX_RECORD_DURATION_SECONDS] (the + * `CMSensorRecorder` ceiling). + */ + internal fun computeRecordDuration( + taskStart: Instant?, + taskStop: Instant?, + now: Instant + ): Double { + if (taskStart == null || taskStop == null || taskStop <= now) { + return DEFAULT_RECORD_DURATION_SECONDS + } + val effectiveStart = if (taskStart < now) now else taskStart + return (taskStop - effectiveStart).inWholeSeconds.toDouble() + .coerceAtMost(MAX_RECORD_DURATION_SECONDS) + } + + /** + * Seconds remaining in the task window to re-arm [BackgroundAccelerometerCollector.record] + * for on a background poll run, or `null` when the window is already over (nothing to + * re-arm for). + */ + internal fun computeReArmDuration(taskStop: Instant?, now: Instant): Double? { + if (taskStop == null || taskStop <= now) return null + return (taskStop - now).inWholeSeconds.toDouble() + .coerceAtMost(MAX_RECORD_DURATION_SECONDS) + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectCollector.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectCollector.kt new file mode 100644 index 00000000..f1d19ded --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectCollector.kt @@ -0,0 +1,47 @@ +package io.redlink.more.observations.healthConnect + +import io.redlink.more.HEALTH_COLLECTOR_GROUP +import io.redlink.more.observations.BundledPermissionCollector +import io.redlink.more.observations.healthConnect.model.HealthConnectSample +import kotlin.time.Instant + +/** + * Platform integration point for a single Health Connect subtype (e.g. heart rate, steps). + * Implemented once per subtype per platform (Android: Health Connect Client, iOS: HealthKit) and + * injected into the single, shared [HealthConnectObservation]. + * + * Adding a new subtype means implementing this interface once per platform and registering it + * with [HealthConnectObservation] in the platform `ObservationFactory` - no change to + * [HealthConnectObservation] itself is required. + */ +interface HealthConnectCollector : BundledPermissionCollector { + override val permissionGroup: String + get() = HEALTH_COLLECTOR_GROUP + val dataType: HealthConnectDataType + override val permissionKey: String + get() = dataType.subTypeValue + + suspend fun collect( + from: Instant, + to: Instant + ): List + + /** + * Total distance walked/run in the window, for [HealthConnectDataType.aggregatesDaily] + * subtypes that have an associated distance metric (steps). Not implemented by every + * collector - unrelated subtypes (e.g. heart rate) keep the `null` default. + */ + suspend fun collectDistanceInMeters(from: Instant, to: Instant): Double? = null + + /** + * True when a bonus permission (e.g. steps' distance, see [collectDistanceInMeters]) has + * never been requested, even though [permissionState] already reports + * [io.redlink.more.services.store.PermissionApprovalState.GRANTED] for the primary metric - + * [permissionState] deliberately ignores bonus fields so a denial there can't block the + * primary metric, which means it's also the only signal + * [HealthConnectObservation.checkRequiredCollectorPermissions] has that a request is still + * owed once the primary metric is already settled. Not implemented by every collector - + * subtypes without a bonus field (e.g. heart rate) keep the `false` default. + */ + suspend fun hasUnrequestedBonusPermission(): Boolean = false +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectDataType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectDataType.kt new file mode 100644 index 00000000..33af8c9a --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectDataType.kt @@ -0,0 +1,50 @@ +package io.redlink.more.observations.healthConnect + +import dev.icerock.moko.resources.StringResource +import io.redlink.more.HEALTH_CONNECT_PREFIX +import io.redlink.more.SharedRes + +/** + * One entry per Health Connect subtype. Adding a new subtype means: + * 1. one new entry here, with the exact subtype value string also registered in + * `HEALTH_CONNECT.subTypes` in [io.redlink.more.observations.observationTypes.ObservationType], + * plus the [valueKey] under which [HealthConnectValueFormatter][io.redlink.more.formatter.HealthConnectValueFormatter] + * reads the stored value (at the root of the sample payload) and the [label]/[unit] shown + * in the current-value display, + * 2. one new [io.redlink.more.observations.healthConnect.model.HealthConnectSample] case, + * 3. one new [HealthConnectCollector] implementation per platform, + * 4. [aggregatesDaily] = true if the raw provider samples are per-interval rather than a running + * total (e.g. steps) - [io.redlink.more.observations.healthConnect.HealthConnectObservation] + * then sums a full calendar day's samples into one stored data point instead of storing each + * interval separately. + * + * ponytail: valueKey/label/unit/aggregatesDaily are constant per subtype (no per-observation JSON + * config), so they live on the enum instead of a resolver. If a subtype ever needs config-driven + * target/fallback values, promote these into a `HealthConnectDisplayDefinition`/ + * `HealthConnectDisplayResolver` pair mirroring [io.redlink.more.resolver.ExternalGoalDisplayResolver]. + */ +enum class HealthConnectDataType( + val subTypeValue: String, + val valueKey: String, + val label: StringResource, + val unit: StringResource, + val aggregatesDaily: Boolean = false, +) { + HEART_RATE( + subTypeValue = "$HEALTH_CONNECT_PREFIX-heart-rate-observation", + valueKey = "hr", + label = SharedRes.strings.external_goal_heart_rate_label, + unit = SharedRes.strings.external_goal_heart_rate_unit, + ), + STEPS( + subTypeValue = "$HEALTH_CONNECT_PREFIX-steps-observation", + valueKey = "steps", + label = SharedRes.strings.external_goal_steps_label, + unit = SharedRes.strings.health_steps_unit, + aggregatesDaily = true, + ); + + companion object { + fun fromObservationType(type: String) = entries.firstOrNull { it.subTypeValue == type } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservation.kt new file mode 100644 index 00000000..cbdf9df7 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservation.kt @@ -0,0 +1,347 @@ +package io.redlink.more.observations.healthConnect + +import dev.icerock.moko.resources.desc.StringDesc +import io.github.aakira.napier.Napier +import io.redlink.more.database.entities.ObservationDataEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.events.TimedEventBus +import io.redlink.more.extensions.asString +import io.redlink.more.extensions.desc +import io.redlink.more.extensions.localDate +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.observations.Observation +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.healthConnect.model.HealthConnectSample +import io.redlink.more.observations.observers.ManualObserver +import io.redlink.more.scopes.Scope +import io.redlink.more.services.store.PermissionApprovalState +import io.redlink.more.viewModels.ViewManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * Single shared Observation serving every currently active Health Connect subtype (heart rate, + * steps, ...). Actual collection/transformation is delegated to the injected [collectors], one per + * subtype per platform - adding a new subtype requires no change here, only a new + * [HealthConnectDataType] entry, [io.redlink.more.observations.healthConnect.model.HealthConnectSample] + * case, and a [HealthConnectCollector] implementation per platform. + */ +class HealthConnectObservation( + repos: MainRepository, + private val observationFactory: ObservationFactory? = null, + private val collectors: List +) : Observation( + repos, + HealthConnectObservationType() +), ManualObserver { + private val permissionStates = mutableMapOf() + private var taskStart: Instant? = null + private var taskStop: Instant? = null + private var pollingJob: Job? = null + + constructor( + repos: MainRepository, + collectors: List + ) : this(repos, null, collectors) + + init { + Scope.launch(Dispatchers.Default) { + observationFactory?.studyObservationTypes?.collect { + checkRequiredCollectorPermissions() + } + } + Scope.launch(Dispatchers.Default) { + TimedEventBus.subscribe { + collectFromActiveCollectors() + } + } + Scope.launch(Dispatchers.Default) { + ViewManager.appInForeground.collect { + if (it) { + collectFromActiveCollectors() + } + } + } + } + + private fun activeCollectors(): List { + val studyTypes = observationFactory?.studyObservationTypes?.value ?: emptySet() + val scheduleTypes = observationIds.mapNotNull { observationTypeFor(it) }.toSet() + val activeTypes = studyTypes + scheduleTypes + return collectors.filter { it.dataType.subTypeValue in activeTypes } + } + + override suspend fun updateObservationPermissions() { + permissionQueryMutex.withLock { + checkRequiredCollectorPermissions() + } + } + + /** + * Queries the collectors required by the currently registered observations + * and returns their current permission states. + */ + internal suspend fun checkRequiredCollectorPermissions(): Map { + val active = activeCollectors() + val states = permissionStates(active) + val missing = active.filter { + states[it.permissionKey] == PermissionApprovalState.NOT_SET || + it.hasUnrequestedBonusPermission() + } + if (missing.isNotEmpty()) { + requestPermissions(missing) + } + val refreshedStates = permissionStates(active) + active.forEach { collector -> + val state = refreshedStates[collector.permissionKey] ?: PermissionApprovalState.NOT_SET + permissionStates[collector.dataType] = state + if (state == PermissionApprovalState.DECLINED) { + showMissingPermissionAlert(collector.dataType.titleStringDesc()) + } + } + return active.associate { collector -> + collector.dataType to (refreshedStates[collector.permissionKey] + ?: PermissionApprovalState.NOT_SET) + } + } + + private fun HealthConnectDataType.titleStringDesc(): StringDesc = when (this) { + HealthConnectDataType.HEART_RATE -> HealthConnectStrings.heartRateTypeString + HealthConnectDataType.STEPS -> HealthConnectStrings.stepsTypeString + }.desc() + + override fun start(): Boolean { + if (observerAccessible()) { + Scope.launch(Dispatchers.Default) { + collectFromActiveCollectors() + } + return true + } + return false + } + + override fun stop(onCompletion: () -> Unit) { + pollingJob?.cancel() + pollingJob = null + onCompletion() + } + + override fun applyObservationConfig(settings: Map) { + taskStart = (settings[CONFIG_TASK_START] as? Long)?.let(Instant::fromEpochSeconds) + taskStop = (settings[CONFIG_TASK_STOP] as? Long)?.let(Instant::fromEpochSeconds) + } + + override fun pollIntervalMillis(): Long = POLL_INTERVAL_MILLIS + + /** + * Entry point for a background poll run (WorkManager `Worker`/`BGAppRefreshTask`) that may + * happen without this observation ever having gone through its normal `start()` lifecycle - + * first registers the schedules that were active since the last collection, then collects. + */ + override suspend fun collectAllData() { + registerRecentSchedules() + collectFromActiveCollectors() + } + + override fun observerErrors(): Set { + return activeCollectors().mapNotNullTo(mutableSetOf()) { collector -> + if (permissionStates[collector.dataType] != PermissionApprovalState.GRANTED) { + "error_health_connect_${collector.dataType.name.lowercase()}_permission" + } else null + } + } + + internal suspend fun collectFromActiveCollectors() { + val schedules = + scheduleIds.keys.mapNotNull { repos.schedule.scheduleWithId(it).firstOrNull() } + val now = Clock.System.now() + val collectorTimeframes = schedules.mapNotNull { schedule -> + val start = schedule.start?.let { Instant.fromEpochSeconds(it) } ?: taskStart + val end = schedule.end?.let { Instant.fromEpochSeconds(it) } ?: taskStop + val dataType = HealthConnectDataType.fromObservationType(schedule.observationType) + // Aggregating subtypes (steps) always re-sum the whole day rather than picking up + // where the last poll left off - HealthKit/Health Connect only return samples + // *starting* inside the query window, so an incremental window would silently drop + // any interval straddling the boundary. Re-summing from local midnight is idempotent. + val lastCollected = if (dataType?.aggregatesDaily == true) { + now.localDate().atStartOfDayIn(TimeZone.currentSystemDefault()) + .let { Instant.fromEpochMilliseconds(it.toEpochMilliseconds()) } + } else { + repos.observation.latestDataPointTimestamp(schedule.observationType) + ?.let { Instant.fromEpochMilliseconds(it) } + ?: start + ?: getLastCollectionTimestamp() + ?: return@mapNotNull null + } + + computeWindow( + lastCollected, + start, + end, + now + )?.let { timeframe -> + schedule.observationType to timeframe + } + } + .toMap() + .ifEmpty { return } + + + val active = activeCollectors() + val states = permissionStates(active) + active.forEach { collector -> + val permissionState = states[collector.permissionKey] ?: PermissionApprovalState.NOT_SET + permissionStates[collector.dataType] = permissionState + if (permissionState != PermissionApprovalState.GRANTED) { + Napier.w(tag = "HealthConnectObservation") { + "Permission not granted for ${collector.dataType}, skipping collection." + } + return@forEach + } + if (collector.dataType.subTypeValue in collectorTimeframes.keys) { + collectorTimeframes[collector.dataType.subTypeValue]?.let { (from, to) -> + runCatching { + if (collector.dataType.aggregatesDaily) { + storeAggregatedSample(collector, collector.collect(from, to), from, to) + } else { + collector.collect(from, to).forEach { sample -> + storeSample(collector, sample) + } + } + }.onFailure { + Napier.w(throwable = it) { "Failed to collect ${collector.dataType}, skipping." } + } + } + } + } + collectionTimestampToNow() + } + + /** + * Stores a sample only for the observationIds matching the collector's own subtype - unlike + * the generic `Observation.storeData`, which would tag/replicate the data point for every + * currently active observationId of this shared instance (heart rate + steps alike). + */ + private suspend fun storeSample( + collector: HealthConnectCollector, + sample: HealthConnectSample + ) { + Napier.d { "Collected data: $sample" } + val targetObservationIds = + observationIds.filter { observationTypeFor(it) == collector.dataType.subTypeValue } + if (targetObservationIds.isEmpty()) return + val entities = targetObservationIds.map { id -> + ObservationDataEntity.fromData( + sample.transform(), + sample.timestamp.toEpochMilliseconds() + ) + .copy( + observationId = id, + observationType = collector.dataType.subTypeValue, + ) + } + + Napier.d { "New HC Observations: $entities" } + dataManager?.add(entities, scheduleIds.keys) + + val rawData = sample.transform() + scheduleIds.filterValues { it in targetObservationIds } + .forEach { (scheduleId, observationId) -> + storeLatestDataPoint( + scheduleId = scheduleId, + observationId = observationId, + observationType = collector.dataType.subTypeValue, + data = rawData, + timestamp = sample.timestamp.toEpochMilliseconds() + ) + } + } + + /** + * Folds a window's worth of per-interval samples (e.g. HealthKit/Health Connect step + * records) into a single daily total instead of storing each interval separately - unlike + * [storeSample], which stores every sample verbatim, this is only used for + * [HealthConnectDataType.aggregatesDaily] subtypes. Skips the write entirely when the freshly + * summed total matches what is already stored, so an idle 15-minute poll doesn't enqueue a + * duplicate upload. + */ + private suspend fun storeAggregatedSample( + collector: HealthConnectCollector, + samples: List, + windowStart: Instant, + windowEnd: Instant + ) { + val stepSamples = samples.filterIsInstance() + if (stepSamples.isEmpty()) return + + val targetObservationIds = + observationIds.filter { observationTypeFor(it) == collector.dataType.subTypeValue } + if (targetObservationIds.isEmpty()) return + + val stepsGoal = targetObservationIds.firstNotNullOfOrNull { + repos.observation.getObservationByObservationId(it)?.targetSteps() + } + val distanceInMeters = runCatching { + collector.collectDistanceInMeters(windowStart, windowEnd) + }.onFailure { + Napier.w(throwable = it) { "Failed to collect distance for ${collector.dataType}, storing steps without it." } + }.getOrNull() + val last = stepSamples.maxBy { it.end } + + val aggregate = HealthConnectSample.Steps( + timestamp = last.end, + count = stepSamples.sumOf { it.count }, + start = stepSamples.minOf { it.start }, + end = last.end, + device = last.device, + sourceApp = last.sourceApp, + stepsGoal = stepsGoal, + distanceInMeters = distanceInMeters + ) + + val rawData = aggregate.transform() + val encoded = rawData.asString() + val targetSchedules = scheduleIds.filterValues { it in targetObservationIds } + val unchanged = + targetSchedules.keys.isNotEmpty() && targetSchedules.keys.all { scheduleId -> + repos.observation.latestDataPointForSchedule(scheduleId) + .firstOrNull()?.dataValue == encoded + } + if (unchanged) return + + Napier.d { "Collected daily aggregate: $aggregate" } + val entities = targetObservationIds.map { id -> + ObservationDataEntity.fromData(rawData, aggregate.timestamp.toEpochMilliseconds()) + .copy( + observationId = id, + observationType = collector.dataType.subTypeValue, + ) + } + dataManager?.add(entities, scheduleIds.keys) + + targetSchedules.forEach { (scheduleId, observationId) -> + storeLatestDataPoint( + scheduleId = scheduleId, + observationId = observationId, + observationType = collector.dataType.subTypeValue, + data = rawData, + timestamp = aggregate.timestamp.toEpochMilliseconds() + ) + } + } + + companion object { + private const val POLL_INTERVAL_MILLIS = 15 * 60 * 1000L + } +} + +private fun ObservationEntity.targetSteps(): Int? = + (configAsMap()["targetSteps"] as? JsonPrimitive)?.intOrNull diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservationType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservationType.kt new file mode 100644 index 00000000..0db0d513 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservationType.kt @@ -0,0 +1,10 @@ +package io.redlink.more.observations.healthConnect + +import io.redlink.more.HEALTH_CONNECT_PREFIX +import io.redlink.more.observations.observationTypes.ObservationType + +class HealthConnectObservationType : ObservationType( + observationType = "$HEALTH_CONNECT_PREFIX-observation", + sensorPermissions = emptySet(), + prefix = "$HEALTH_CONNECT_PREFIX-" +) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectStrings.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectStrings.kt new file mode 100644 index 00000000..88419eef --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectStrings.kt @@ -0,0 +1,17 @@ +package io.redlink.more.observations.healthConnect + +import dev.icerock.moko.resources.StringResource + +/** + * Platform-specific wording for the Health Connect provider (e.g. "Google Health Connect" on + * Android vs. "Apple Health" on iOS) and its subtypes. + * + * Adding a new Health Connect subtype only requires adding a new string resource here (plus the + * matching HealthConnectDataType/HealthConnectSample/HealthConnectCollector). + */ +expect object HealthConnectStrings { + val providerTypeString: StringResource + val providerShortTypeString: StringResource + val heartRateTypeString: StringResource + val stepsTypeString: StringResource +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/model/HealthConnectSample.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/model/HealthConnectSample.kt new file mode 100644 index 00000000..dc45dc99 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/healthConnect/model/HealthConnectSample.kt @@ -0,0 +1,61 @@ +package io.redlink.more.observations.healthConnect.model + +import io.redlink.more.observations.healthConnect.HealthConnectDataType +import kotlin.time.Instant + +/** + * Health transformation model: the typed sample a + * [io.redlink.more.observations.healthConnect.HealthConnectCollector] produces from the platform + * SDK, transformed into the generic payload consumed by `Observation.storeData`. The payload shape + * mirrors `openapi/HealthTransformationAPI.yaml`'s `TimeData`/`StepData`/`HeartRateData` models + * (`timestamp`/`startTime`/`endTime`/`device`/`data`/`additionalData`) - those generated classes + * aren't compiled in because their `oneOf` union collapses into a single class with mutually + * exclusive fields marked `@Required`, so the shape is reproduced here as a plain map instead. + * + * Adding a new subtype means adding a new case here plus a `transform()` branch. + */ +sealed class HealthConnectSample( + val timestamp: Instant, + /** Recording hardware (e.g. "Apple Watch"), when the provider exposes it. */ + val device: String? = null, + /** App that wrote the sample (e.g. "com.apple.health"), surfaced via `additionalData`. */ + val sourceApp: String? = null, +) { + class HeartRate( + timestamp: Instant, + val bpm: Int, + device: String? = null, + sourceApp: String? = null, + ) : HealthConnectSample(timestamp, device, sourceApp) + + class Steps( + timestamp: Instant, + val count: Long, + val start: Instant, + val end: Instant, + device: String? = null, + sourceApp: String? = null, + /** Only set on the daily-aggregate sample built by `HealthConnectObservation`. */ + val stepsGoal: Int? = null, + /** Only set on the daily-aggregate sample built by `HealthConnectObservation`. */ + val distanceInMeters: Double? = null, + ) : HealthConnectSample(timestamp, device, sourceApp) + + fun transform(): Map = buildMap { + put("timestamp", timestamp.toString()) + device?.let { put("device", it) } + sourceApp?.let { put("additionalData", mapOf("sourceApp" to it)) } + when (this@HealthConnectSample) { + is HeartRate -> put("data", mapOf(HealthConnectDataType.HEART_RATE.valueKey to bpm)) + is Steps -> { + put("startTime", start.toString()) + put("endTime", end.toString()) + put("data", buildMap { + put(HealthConnectDataType.STEPS.valueKey, count) + stepsGoal?.let { put("stepsGoal", it) } + distanceInMeters?.let { put("distanceInMeters", it) } + }) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/ObservationType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/ObservationType.kt index 85401026..1df34b88 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/ObservationType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/ObservationType.kt @@ -15,7 +15,8 @@ open class ObservationType( val sensorPermissions: Set, val prefix: String? = null, val suffix: String? = null, - val includes: String? = null + val includes: String? = null, + val dependentObservationTypes: Set = emptySet() ) { @@ -27,4 +28,4 @@ open class ObservationType( } fun matchesAny(types: Set): Boolean = types.any { matches(it) } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/observers/ManualDataCollection.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observers/ManualDataCollection.kt new file mode 100644 index 00000000..45e8c63f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observers/ManualDataCollection.kt @@ -0,0 +1,5 @@ +package io.redlink.more.observations.observers + +interface ManualObserver { + suspend fun collectAllData() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/polling/PollingObservationRegistry.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/polling/PollingObservationRegistry.kt new file mode 100644 index 00000000..2e59da16 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/polling/PollingObservationRegistry.kt @@ -0,0 +1,63 @@ +package io.redlink.more.observations.polling + +import io.github.aakira.napier.Napier +import io.redlink.more.extensions.jsonRead +import io.redlink.more.extensions.jsonString +import io.redlink.more.services.store.SharedStorageRepository + +/** + * Tracks which observation types currently want background polling and keeps the single shared + * [PollingTaskScheduler] request (one Worker on Android, one BGAppRefreshTask on iOS) in sync + * with that set, without ever resubmitting an already-active request for an unchanged interval. + * Persisted so a worker/task run in a fresh process (app not running) still knows which + * observation types are activated. + */ +object PollingObservationRegistry { + private var scheduler: PollingTaskScheduler? = null + private var storage: SharedStorageRepository? = null + private val activeIntervals = mutableMapOf() + + fun init(scheduler: PollingTaskScheduler?, storage: SharedStorageRepository) { + this.scheduler = scheduler + this.storage = storage + activeIntervals.clear() + activeIntervals.putAll(loadPersisted(storage)) + } + + fun activate(observationType: String, intervalMillis: Long) { + if (activeIntervals[observationType] == intervalMillis) { + return + } + activeIntervals[observationType] = intervalMillis + persist() + Napier.i(tag = "PollingObservationRegistry::activate") { "Activated background polling for $observationType every ${intervalMillis}ms" } + scheduler?.schedule(activeIntervals.values.min()) + } + + fun deactivate(observationType: String) { + if (activeIntervals.remove(observationType) == null) { + return + } + persist() + Napier.i(tag = "PollingObservationRegistry::deactivate") { "Deactivated background polling for $observationType" } + if (activeIntervals.isEmpty()) { + scheduler?.cancel() + } else { + scheduler?.schedule(activeIntervals.values.min()) + } + } + + fun activeObservationTypes(): Set = activeIntervals.keys.toSet() + + private fun persist() { + storage?.store(STORAGE_KEY, activeIntervals.jsonString()) + } + + private fun loadPersisted(storage: SharedStorageRepository): Map { + val raw = storage.load(STORAGE_KEY, "") + if (raw.isBlank()) return emptyMap() + return raw.jsonRead>() ?: emptyMap() + } + + private const val STORAGE_KEY = "pollingObservationRegistryActiveIntervals" +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/polling/PollingTaskScheduler.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/polling/PollingTaskScheduler.kt new file mode 100644 index 00000000..e9c79bf3 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/polling/PollingTaskScheduler.kt @@ -0,0 +1,12 @@ +package io.redlink.more.observations.polling + +/** + * Platform bridge for the single, shared background poll request (a WorkManager `Worker` on + * Android, a `BGAppRefreshTask` on iOS) that periodically wakes the app to let currently + * [io.redlink.more.observations.Observation.activate]d polling observations collect data while + * the app is not in the foreground. + */ +interface PollingTaskScheduler { + fun schedule(intervalMillis: Long) + fun cancel() +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt b/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt index 88eaad8c..b3376e22 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt @@ -2,13 +2,13 @@ package io.redlink.more.registration import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.github.aakira.napier.Napier -import io.ktor.util.encodeBase64 import io.ktor.utils.io.core.toByteArray import io.redlink.more.Shared import io.redlink.more.app.android.services.network.errors.NetworkServiceError import io.redlink.more.getPlatform import io.redlink.more.models.CredentialModel import io.redlink.more.models.LoginModel +import io.redlink.more.scopes.AppDispatchers import io.redlink.more.scopes.Scope import io.redlink.more.services.network.openapi.model.ObservationConsent import io.redlink.more.services.network.openapi.model.Study @@ -19,6 +19,7 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.kotlincrypto.hash.md.MD5 +import kotlin.io.encoding.Base64 open class RegistrationService( private val shared: Shared, @@ -61,6 +62,15 @@ open class RegistrationService( _error.value = null } + fun beginConsentSubmission() { + clearError() + _isLoading.value = true + } + + fun cancelConsentSubmission() { + _isLoading.value = false + } + open fun sendRegistrationToken( loginModel: LoginModel ) { @@ -86,30 +96,33 @@ open class RegistrationService( uniqueDeviceId: String, ) { clearError() - validLoginModel.value?.let { loginModel -> - study.value?.let { study -> - val studyConsent = StudyConsent( - consent = true, - observations = study.observations.map { - ObservationConsent( - observationId = it.observationId, - active = true - ) - }, - consentInfoMD5 = MD5().digest(study.consentInfo.toByteArray()) - .encodeBase64(), - deviceId = "${getPlatform().productName}#$uniqueDeviceId" - ) - sendConsent(studyConsent) - } + val loginModel = validLoginModel.value + val currentStudy = study.value + if (loginModel == null || currentStudy == null) { + _isLoading.value = false + return } + val studyConsent = StudyConsent( + consent = true, + observations = currentStudy.observations.map { + ObservationConsent( + observationId = it.observationId, + active = true + ) + }, + consentInfoMD5 = Base64.encode( + MD5().digest(currentStudy.consentInfo.toByteArray()) + ), + deviceId = "${getPlatform().productName}#$uniqueDeviceId" + ) + sendConsent(studyConsent) } private fun sendConsent( studyConsent: StudyConsent, ) { _isLoading.value = true - Scope.launch(Dispatchers.IO) { + Scope.launch(AppDispatchers.io) { val (config, networkError) = shared.networkService.sendConsent( _validLoginModel.value!!, studyConsent @@ -143,7 +156,7 @@ open class RegistrationService( } }.second.invokeOnCompletion { _isLoading.value = false - Scope.launch(Dispatchers.IO) { + Scope.launch(AppDispatchers.io) { if (shared.credentialRepository.hasCredentials.value) { clearError() _validLoginModel.value = null diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkWatcher.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkWatcher.kt new file mode 100644 index 00000000..2223b53e --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkWatcher.kt @@ -0,0 +1,17 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ +package io.redlink.more.services.network + +import kotlinx.coroutines.flow.Flow + +interface NetworkWatcher { + fun watchNetworkState(): Flow +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/network/demo/DemoData.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/network/demo/DemoData.kt index 7fc36f51..7625e0bc 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/services/network/demo/DemoData.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/demo/DemoData.kt @@ -1,5 +1,6 @@ package io.redlink.more.services.network.demo +import io.redlink.more.observations.healthConnect.HealthConnectDataType import io.redlink.more.services.network.openapi.model.ApiKey import io.redlink.more.services.network.openapi.model.AppConfiguration import io.redlink.more.services.network.openapi.model.ContactInfo @@ -151,6 +152,36 @@ object DemoData { // required = false, // version = now.toEpochMilliseconds() // ) + Observation( + observationId = "9", + observationType = HealthConnectDataType.HEART_RATE.subTypeValue, + observationTitle = "Heart Rate", + participantInfo = "Heart rate monitoring via Health Connect.", + schedule = listOf( + ObservationSchedule( + start = observationStart, + end = observationEnd + ) + ), + required = false, + hidden = false, + version = now.toEpochMilliseconds() + ), + Observation( + observationId = "10", + observationType = HealthConnectDataType.STEPS.subTypeValue, + observationTitle = "Steps", + participantInfo = "Step count monitoring via Health Connect.", + schedule = listOf( + ObservationSchedule( + start = observationStart, + end = observationEnd + ) + ), + required = false, + hidden = false, + version = now.toEpochMilliseconds() + ), // hidden observation Observation( observationId = "1", diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt index c94644a7..78b7f3ad 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt @@ -37,6 +37,7 @@ object ViewManager { private val _appInForeground = MutableStateFlow(false) private val _bleViewOpen = MutableStateFlow(false) private val _activeStudy = MutableStateFlow(false) + private val _networkConnected = MutableStateFlow(true) @NativeCoroutines val studyLoadingError: StateFlow = _studyLoadingError @@ -59,6 +60,9 @@ object ViewManager { @NativeCoroutines val activeStudy: StateFlow = _activeStudy + @NativeCoroutines + val networkConnected: StateFlow = _networkConnected + private fun canOpenNewView(): Boolean { return _activeStudy.value && !_studyIsUpdating.value && @@ -132,6 +136,10 @@ object ViewManager { _studyLoadingError.value = hasError } + fun networkConnected(state: Boolean) { + _networkConnected.value = state + } + fun appIsInForeground(state: Boolean) { if (state) { LogEvent.APP_IN_FOREGROUND.track() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt index 118f81a4..74bc7388 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt @@ -74,18 +74,20 @@ class QuestionCoreViewModel( "Questionnaire answered, but not yet sent, for Observation ID: $observationId" ) observation?.let { observation -> - observation.start( - questionModel.observationId, - questionModel.scheduleId, - notificationId - ) - observation.storeData(mapOf(questionModel.type.observationDataResponseKey to data)) { - Napier.event( - LogEvent.OBSERVATION_EVENT, - "Questionnaire answer successfully sent with Observation ID: $observationId" + launchScope { + observation.start( + questionModel.observationId, + questionModel.scheduleId, + notificationId ) - scheduleId?.let { - observation.stopAndSetDone(it) + observation.storeData(mapOf(questionModel.type.observationDataResponseKey to data)) { + Napier.event( + LogEvent.OBSERVATION_EVENT, + "Questionnaire answer successfully sent with Observation ID: $observationId" + ) + scheduleId?.let { + observation.stopAndSetDone(it) + } } } } diff --git a/shared/src/commonMain/moko-resources/base/strings.xml b/shared/src/commonMain/moko-resources/base/strings.xml index 2a021ec5..4a5a6a5c 100644 --- a/shared/src/commonMain/moko-resources/base/strings.xml +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -1,4 +1,222 @@ + + + Close + + MORE + + + Today + Goals + Notifications + Health + Profile + + + Today\'s tasks + You currently have no tasks to complete + No current activity + You currently have no activity, but you can create goals. Go to + Goals and get started. + + Timeline + %1$s - %2$s + Current + There are currently no additional hints, reminders, or + questionnaires. + + Reminder + Snooze + Close + Remind later + Confirm + Now active + Upcoming + Completed + min. + h + Quick select + Snoozed + Scheduled for %s + Cancel Snooze + Are you sure you want to cancel the snooze for + this reminder? + + + + Questionnaire + Google Health Connect + Health Connect + Apple Health + Apple Health + Heart Rate + Steps + Health Connect Not Installed + Health Connect is required to read your + health data on this device. Would you like to install it from the Play Store? + + Install + Permission Missing + Permission to read %1$s is missing. + Please grant it in Settings to continue collecting this data for the study. + + + + Add Goal + No goals yet. Add your first goal! + No goals were configured for this study. + Here should be your progress + Delete Goal + Are you sure you want to delete this goal? + + + Setup Goals + Setup: Create Goal + Setup: Edit Goal + Setup Baseline Tracking + Goal Creation Type + Do you want to track a baseline or create normal goals? + There are no goal templates provided for this + study + + Goals cannot have the same title + + Select your goal areas + Available topics + Select & configure goals + Available templates + Adjust your goals + Title + Amount: %d %s + Days per week: %d + Summary & Commitment + Your selected goals: + Template: %s + Please confirm the following: + Please confirm all points before saving. + + Delete + Delete Goal + Are you sure you want to delete the goal + "%s"? + + Close delete goal view + Duplicate + Modified + Adherence Checks + + I will record and track my activities and + tasks in the app every day. + + I understand what I am committing to. + This feels achievable for my current recovery + status. + + I know how to record these goals in the + app. + + + + Morning + Noon + Afternoon + Evening + Night + %1$s (%2$s) + %1$s: %2$s + + Error + OK + + + No health data yet! + Today + %d Days + Last %d Days + Goal: %1$d %2$s + Steps + Steps + steps + Glucose + mg/dL + Heart rate + bpm + Resting heart rate + Blood pressure + mmHg + Sleep + h + Steps History + Distance: %.1f km + Trend: %d%% + steigend + fallend + gleichbleibend + Garmin + Freestyle Libre + Morning + Noon + Evening + SYS + DIA + Status + Blood Pressure History + Raw Data + Quality: %1$s (%2$d/100) + Type: %s + Score + Sleep History + Data format for %s is not supported. + Visualization %s not supported. + Normal + Glucose History + Heartreate History + Min / Max + Date + Goal range: %s + Hyper: %1$s | Hypo: %2$s + + Bar Chart + Scatter Plot + Bar Range Chart + Count + Step goal + Resting + Min + Max + Duration + Quality + Score + + + Goal + Eating habit + Drinking habit + Reduce habit + Simple Task + External Goal + Step goal + + + Step %d of %d + Back + Next + Save + Loading + Delete + Cancel + + Week + Close week selection picker + Select Date + Data loading + No data + + Last recording: %s + + + + Please open the app and start the observation! app @@ -11,6 +229,10 @@ Allow Deny App Tracking Disabled + App-Tracking + The App Usage is used to support improving the study and + application. + App usage tracking is disabled. Please enable it in the study settings. If it's already enabled there, please check your general iOS privacy settings for "Tracking". @@ -20,7 +242,330 @@ Settings + %s accepted + %s declined + All Notifications Unread Important + Entire Time + Today and Tomorrow + One Week + One Month + Dashboard + Notifications + Information + Settings + Task Details + Observation Details + Study Details + Observation Filter + Notification Filter + Simple Question + Running Observations + Past Observations + Leave Study + Confirm to leave the study + Limesurvey + Observation Errors + Garmin Connect + Devices + Scan QR Code + %d notifications + Not connected to the PraeCura system. Please contact your Study Administrator for further questions. + Study + Privacy & Permissions + Connected Devices + Account + Contact + Collected Data + App Permissions + App-Tracking + + Connected Services + Garmin Connect + FreeStyle Libre + Connected + Not connected + Connect + Disconnect + Open App + %1$s, %2$s + Open %1$s app + Connect %1$s + Disconnect %1$s + + Garmin Connect is currently unavailable. Please try + again later. + + Close + + Complete Study + Finish the study and submit all data + Leave Study + Withdraw and leave the study + Important Notice + Continue participating + If you leave the study, you will no longer be able + to participate. Your study participation will be ended, and all recorded data will + be deleted from your device. + + Continue participating! + Load Permissions… + All necessary permissions have been + accepted. If you want to withdraw permissions, you can only do so by withdrawing from the + study. + + Refresh Study Configurations + OK + Error updating study! + Leave Study + If you withdraw from the study, you will not be + able to re-enter at a later date. + + If you leave this study, you may not + re-enter. Your participation will be cancelled and your data will be deleted from your + mobile phone! + + Do you really want to withdraw? + Are you sure you want to withdraw your + participation? + + Swipe to withdraw + Click to withdraw + Continue to participate + Withdraw from the study + Read more + Read less + Decline + Accept + Participant Information + + No contact information was configured. Please contact + your original study organiser if needed. + + + + Offboarding + Study Completed + Data Safety + Your participation has ended. + All your data collected during this study has been securely processed. + Local data on this device will be removed once you exit. + + If you complete the study, your participation will + be ended, and all remaining data will be submitted. + + Exit Study + Call + Send Email + Study Details + Study Info + Study + Period + Facility + Study Lead + Participant ID + Consent + + Online + Offline + A network connection is required to save your + goals. Please check your connection and try again. + + Goals saved successfully. + Baseline Tracking + Goals + Baseline Tracking + Create/Modify Goals + Create/Modify Baseline Tracking + Select Baseline Tracking + Select the templates for baseline + tracking + + Edit Goals + Edit your goals + Create Baseline Tracking + Success + Your goals have been successfully created. + Dismiss + Convert to Goal + Set Reminder + Reminder: %s + Enable Reminder + Time + Custom Message (optional) + Save + Reminder: %s + Morning Reminder + Noon Reminder + Evening Reminder + Reminders May Be Delayed + You disabled the "Alarms & reminders" + permission for this app. Reminders may fire late or not at all. Enable it under Settings → + Apps → %1$s → Alarms & reminders → Allow setting alarms and reminders. + + Notifications May Be Summarized + + Reminders for this app are set to be + delivered as part of a scheduled summary. This may delay when you see them. Disable this + under Settings → Notifications → Scheduled Summary and remove %1$s from the summary. + + Settings + Continue Anyway + You have a new reminder. + %1$s You did great on your goal "%2$s", let's keep the pace up today! + %1$s Almost had it yesterday on "%2$s", you can do it today! + %1$s You were over your limit yesterday on "%2$s". Try to reduce your consumption today! + A network connection is required to access this feature. + Please check your connection and try again. + + An error occurred while loading health data. + Goal Adherence + + + You have no notifications yet. + Mark as read + Mark as unread + Delete Notification + Are you sure you want to delete this + notification? + + Reminder + Important + + + Welcome to More + Study Endpoint + Edit Study Endpoint + Enter Registration Token + Enter Token + Login + Enter Study URL + Token Error + Token or Endpoint invalid + System Error! Please try again later or contact your Study + Administrator! + + Scan QR Code + Open camera to scan a QR code + or + QR Code will be scanned automatically. + Camera permissions needed to scan QR code. + Close QR Code Scanner. + + + Study currently paused + This study is currently paused by the Study Operator and will + be resumed shortly + + The study configuration is currently updating + Please wait until this process is finished + Study is loading… + Study was completed + Thank you for your participation + Message by the Study Administrator + Error loading Study + There was an issue loading your study.\nPlease try + again later or contact your study administrator + + + + Thank You! + Thank You for your participation! + Your answer to the question has been successfully + submitted! + + Submit + Return to Dashboard + + + App Version + Close + Consent + Approved + Cancel + Abort + Danger + Close Overlay + Done + Done + Edit + Reload + No data has been synced or collected for today yet. + + + System error! Could not load limesurvey data! + + Data loading + Cancel Survey + Finish Survey + + + Study Details + Open Study Details + Running Observations + Open Running Observations + Past Observations + Open Past Observations + Devices + Open Devices + Settings + Consent Settings + Open Settings + Leave Study + Exit Application + When encountering problems, feel free to contact us. + Contact + Participant + + + Error + Could not load data + No permission to access bluetooth + Bluetooth disabled + No viable device connected + Location Services return an unknown error + 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! + + Heart-rate measurement feature unavailable + Cannot connect to Garmin Connect. Please try + again later! + + + + No internet connection + Please connect to the internet and try again + No internet connection + Please connect to the internet and try again + + + Start Observation + Pause Observation + Stop Observation + Observation running + Start Questionnaire + Please select an answer! + Simple Questionnaire + Data is currently being recorded + + Data recording is ready + Please open the MORE app to record data + Open Observation + Datapoints recorded + Start LimeSurvey + + + Important Notifications diff --git a/shared/src/commonMain/moko-resources/de/strings.xml b/shared/src/commonMain/moko-resources/de/strings.xml index f6270691..c2fc67f5 100644 --- a/shared/src/commonMain/moko-resources/de/strings.xml +++ b/shared/src/commonMain/moko-resources/de/strings.xml @@ -1,6 +1,233 @@ + + Schließen + + MORE + + + Heute + Ziele + Benachrichtigungen + Gesundheit + Profil + + + Heute zu erledigen + Du hast gerade keine zu erledigenden Aufgaben + Derzeit keine Aktivität + Du hast zurzeit keine Aktivität, kannst aber Ziele erstellen. + Gehe zu „Ziele“ und leg los. + + Timeline + %1$s - %2$s + Aktuell + Zurzeit gibt es keine zusätzlichen Hinweise, Erinnerungen + oder Fragebögen. + + Erinnerung + Snooze + Schließen + Erinnere mich später + Bestätigen + Jetzt aktiv + Anstehend + Abgeschlossen + Min. + Std. + Schnellauswahl + Snooze aktiv + Geplant für %s + Snooze abbrechen + Bist du sicher, dass du den Snooze für diese + Erinnerung abbrechen möchtest? + + + Befragung + Google Health Connect + Health Connect + Apple Health + Apple Health + Herzfrequenz + Schritte + Health Connect nicht installiert + Health Connect wird benötigt, um deine + Gesundheitsdaten auf diesem Gerät zu lesen. Möchtest du es aus dem Play Store installieren? + + Installieren + Berechtigung fehlt + Die Berechtigung zum Lesen von %1$s + fehlt. Bitte erteile sie in den Einstellungen, um diese Daten weiterhin für die Studie zu + erfassen. + + + + + + Ziel hinzufügen + Noch keine Ziele. Bitte welche hinzufügen! + Für diese Studie wurden keine Ziele konfiguriert. + Hier ist ein Stand + Ziel löschen + Bist du sicher, dass du dieses Ziel löschen möchtest? + + + + Ziele einrichten + Setup: Ziele erstellen + Setup: Ziele editieren + Setup Baseline Tracking + Art der Zielerstellung + Möchten Sie eine Baseline erfassen oder normale Ziele erstellen? + Für diese Studie sind keine Zielvorlagen + vorhanden + + Ziele können nicht den gleichen Titel haben + + Wählen Sie Ihre Zielbereiche + Verfügbare Zielbereiche + Ziele auswählen & konfigurieren + Verfügbare Vorlagen + Ihre Ziele anpassen + Titel + Menge: %1$d %2$s + Tage pro Woche: %1$d + Zusammenfassung & Verpflichtung + Ihre gewählten Ziele: + Vorlage: %s + Bitte bestätigen Sie Folgendes: + Löschen + Ziel löschen + Bist du sicher, dass du das Ziel "%s" löschen + möchtest? + + Löschansicht schließen + Duplizieren + Einhaltungsprüfungen + Morgens + Mittags + Nachmittags + Abends + Nachts + %1$s (%2$s) + %1$s: %2$s + Bitte bestätigen Sie alle Punkte vor dem + Speichern. + + Geändert + Offline + Online + + + Ich werde täglich in der App meine Aktivitäten + und Aufgaben eintragen und mittracken. + + Ich verstehe, wozu ich mich verpflichte. + Das fühlt sich für meinen aktuellen + Genesungszustand erreichbar an. + + Ich weiß, wie ich diese Ziele in der App + erfassen kann. + + + + + Noch keine Gesundheitsdaten! + Heute + %d Tage + Letzte %d Tage + Ziel: %1$d %2$s + Zielbereich: %s + Hyper: %1$s | Hypo: %2$s + Schritte + Schritte + Schritte + Glukose + mg/dL + Puls + bpm + Ruhepuls + Blutdruck + mmHg + Schlaf + Std. + Schritte Historie + Distanz: %.1f km + Trend: %d%% + steigend + fallend + gleichbleibend + Garmin + Freestyle Libre + Morgens + Mittags + Abends + SYS + DIA + Status + Blutdruck Historie + Rohdaten + Qualität: %1$s (%2$d/100) + Typ: %s + Score + Schlaf Historie + Datenformat für %s nicht unterstützt. + Visualisierung %s nicht unterstützt. + Normal + Glukose Historie + Puls Historie + Min / Max + Datum + + Balken Diagramm + Punkt Diagramm + Balkenbereich Diagramm + Anzahl + Schritteziel + Ruhepuls + Min + Max + Länge + Qualität + Bewertung + + + + Schritt %1$d von %2$d + Fehler + OK + Zurück + Weiter + Speichern + Lädt + Löschen + Abbrechen + + KW + Datumauswahl schließen + Datum auswählen + Daten laden + Für heute wurden noch keine Daten synchronisiert. + + + + Ziel + Essensgewohnheit + Trinkgewohnheit + Gewohnheit reduzieren + Einfache Aufgabe + Externes Ziel + Schritte Ziel + + Keine Daten + + Letzte Aufnahme: %s + + + + app + io.redlink.umm.blenededcare Bitte öffne die App und starte die Beobachtung! Berechtigung zur App-Nutzungsverfolgung Diese Studie verwendet App-Nutzungsdaten, um zu @@ -12,6 +239,10 @@ Erlauben Verweigern App-Tracking deaktiviert + App-Tracking + Ihre App-Nutzung wird erfasst, um die Studie zu + unterstützen. + Die App-Nutzungsverfolgung ist deaktiviert. Bitte aktivieren Sie diese in den Studieneinstellungen. Wenn sie dort bereits aktiviert ist, überprüfen Sie bitte Ihre allgemeinen iOS-Datenschutzeinstellungen für @@ -22,7 +253,347 @@ Einstellungen + %s akzeptiert + %s abgelehnt + Alle Nachrichten Ungelesen Wichtig + Gesamter Zeitraum + Heute und Morgen + Eine Woche + Ein Monat + Übersicht + Nachrichten + Information + Einstellungen + Aufgabendetails + Observatdetails + Studiendetails + Filter + Filter + Simple Frage + Laufende Beobachtungen + Vergangene Beobachtungen + Studie verlassen + Studie verlassen bestätigen + Limesurvey + Aufzeichnungsfehler + Garmin Connect + Geräte + QR-Code scannen + Nicht mit dem PraeCura-System verbunden. Bitte kontaktieren Sie bei weiteren Fragen Ihren Studien-Administrator. + Studie + Datenschutz & Berechtigungen + Verbundene Geräte + Konto + Kontakt + Gesammelte Daten + App Berechtigungen + App-Tracking + Verbundene Dienste + Garmin Connect + FreeStyle Libre + Verbunden + Nicht verbunden + Verbinden + Trennen + App öffnen + %1$s, %2$s + %1$s App öffnen + %1$s verbinden + %1$s trennen + + Garmin Connect ist derzeit nicht verfügbar. Bitte + versuchen Sie es später erneut. + + Schließen + Studie abschließen + Studie beenden und alle Daten übermitteln? + + Studie verlassen + Weiterhin teilnehmen + Wichtige Meldung + Zurückziehen und die Studie verlassen + Wenn Sie die Studie verlassen, können Sie nicht mehr + teilnehmen. Ihre Studienteilnahme wird beendet und alle aufgezeichneten Daten werden von + Ihrem Gerät gelöscht. + + Teilnahme fortsetzen! + Lade Berechtigungen… + Sie haben alle notwendigen Zustimmungen + erteilt. Sie können die Zustimmmungen zurückziehen, indem Sie die gesamte Studie verlassen. + + Studieneinstellungen aktualisieren + OK + Ein Fehler beim Update der Studie ist + aufgetreten! + + Studie verlassen + Wenn Sie die Studie verlassen, können Sie später + nicht mehr teilnehmen. + + Wenn Sie die Studie verlassen, können Sie + später nicht mehr teilnehmen. Ihre Studienteilnahme wird beendet und alle bisher + aufgezeichneten Daten werden von Ihrem Mobiltelefon gelöscht. + + Möchten Sie wirklich die Studie verlassen? + + Sind Sie sicher, dass sie Ihre Teilnahme + an der Studie beenden wollen? + + Ziehen, um die Studie zu verlassen + Klicken, um auszutreten + Weiterhin teilnehmen! + Die Studie verlassen + Mehr + Weniger + Ablehnen + Akzeptieren + Informationen + + Es wurden keine Kontaktdaten angegeben. Bitte melden Sie + sich bei Fragen an Ihre:n Studienleiter:in. + + + Offboarding + Studie abgeschlossen + Datensicherheit + Ihre Teilnahme wurde beendet. + Alle während dieser Studie erhobenen Daten wurden sicher verarbeitet. + Lokale Daten auf diesem Gerät werden nach dem Verlassen entfernt. + + Wenn Sie die Studie abschließen, wird Ihre + Teilnahme beendet und alle verbleibenden Daten werden übermittelt. + + Studie verlassen + Anrufen + E-Mail schreiben + Studien-Details + Studieninfo + Studie + Zeitraum + Einrichtung + Studienleitung + Teilnehmer-ID + Einverständnis + %d Benachrichtigungen + Eine Netzwerkverbindung ist erforderlich, um Ihre + Ziele zu speichern. Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut. + + Ziele erfolgreich gespeichert. + Baseline Tracking + Ziele + Baseline Tracking + Ziele erstellen/bearbeiten + Baseline Tracking erstellen/bearbeiten + + Baseline Tracking auswählen + Wählen Sie die Vorlagen für das Baseline + Tracking aus + + Ziele bearbeiten + Bearbeiten Sie Ihre Ziele + Baseline Tracking erstellen + Erfolg + Ihre Ziele wurden erfolgreich erstellt. + Schließen + In Ziel umwandeln + Erinnerung einstellen + Erinnerung: %s + Erinnerung aktivieren + Zeit + Eigene Nachricht (optional) + Speichern + Erinnerung: %s + Morgendliche Erinnerung + Mittägliche Erinnerung + Abendliche Erinnerung + Erinnerungen können verspätet sein + Sie haben die Berechtigung „Alarme & + Erinnerungen“ für diese App deaktiviert. Erinnerungen können dadurch verspätet oder gar + nicht + ausgelöst werden. Aktivieren Sie die Berechtigung unter Einstellungen → Apps → %1$s → + Alarme & Erinnerungen → Alarme & Erinnerungen zulassen. + + Mitteilungen können zusammengefasst + werden + + Erinnerungen dieser App werden im + Rahmen einer geplanten Mitteilungsübersicht zugestellt. Dadurch kann sich die Anzeige + verzögern. Deaktivieren Sie dies unter Einstellungen → Mitteilungen → Geplante Übersicht + und entfernen Sie %1$s aus der Übersicht. + + Einstellungen + Trotzdem fortfahren + Eine Netzwerkverbindung ist erforderlich, um auf diese + Funktion zuzugreifen. Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut. + + Beim Laden der Gesundheitsdaten ist ein Fehler + aufgetreten. + + Zieleinhaltung + %1$s Super gemacht bei deinem Ziel "%2$s", bleib dabei! + %1$s Gestern bei "%2$s" fast geschafft, heute klappt es! + %1$s Gestern hast du dein Limit bei "%2$s" überschritten. Versuche heute, deinen Konsum zu reduzieren! + + + Sie haben noch keine Benachrichtigungen. + Als gelesen markieren + Als ungelesen markieren + Benachrichtigung löschen + Sind Sie sicher, dass Sie diese Benachrichtigung + löschen möchten? + + Erinnerung + Wichtig + + + Willkommen bei More + Studienendpunkt + Bearbeite den Studienendpunkt + Bitte geben Sie den Registrierungstoken ein + + Token eingeben + Login + URL der Studie eingeben + Ein Fehler beim Token ist aufgetreten + "Fehler im Token oder in der URL" + System Error! Bitte versuchen Sie es später oder kontaktieren + Sie Ihren Studien-Administrator! + + Scannen Sie bitte Ihren QR Code + Öffnen Sie die Kamera um den QR code zu scannen + + oder + QR Code wird automatisch gescannt. + Um den QR Code zu scannen benötigen wir Zugriff zu deiner + Kamera. + + QR Code Scanner-Overlay schließen + + + Die Studie ist derzeit pausiert + Die Studie ist vom Studienleiter derzeit pausiert und wird in + Kürze fortgesetzt + + Die Studienkonfiguration wird gerade aktualisiert! + Bitte warten Sie kurz, bis die Aktualisierung beendet wurde + + Studie lädt… + Diese Studie wurde beendet + Vielen Dank für Ihre Teilnahme + Nachricht von Ihrem Studien-Administrator + Fehler beim Laden der Studie + Es gab ein Problem beim Laden Ihrer Studie.\nBitte + versuchen Sie es später erneut oder kontaktieren Sie Ihren Studien Administrator + + + + Vielen Dank! + Vielen Dank für Ihre Teilnahme! + Ihre Antwort zur Frage wurde erfolgreich übermittelt! + + Einreichen + Zurück zur Übersicht + + + App Version + Schließen + Zustimmung + Genehmigt + Abbrechen + Abbrechen + Gefahr + Fenster schließen + Fertig + Fertig + Bearbeiten + Neu laden + + + Systemfehler! Konnte nicht die Daten von + LimeSurvey laden! + + Daten werden geladen + LimeSurvey Studie abbrechen + LimeSurvey Studie beenden + + + Studiendetails + Studiendetails öffnen + Laufende Aufzeichnungen + Laufende Aufzeichnungen öffnen + Vergangene Aufzeichnungen + Vergangene Aufzeichnungen öffnen + Geräte + Geräte öffnen + Einstellungen + Zustimmung + Einstellungen öffnen + Studie verlassen + Applikation verlassen + Bei Problemen können Sie sich gerne an uns wenden. + Kontaktdaten + Teilnehmer + + + Fehler + Daten konnten nicht geladen werden + Keine Berechtigung für den Zugriff auf Bluetooth + Bluetooth ist deaktiviert + Kein verfügbares Gerät verbunden + Ortungsdienste geben einen unbekannten Fehler zurück + + 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! + + Funktion zur Messung der Herzfrequenz nicht verfügbar + Es konnte Garmin Connect nicht aufgerufen + werden. Bitte versuchen Sie es später erneut! + + + + Keine Internetverbindung + Bitte verbinden Sie das Gerät mit dem Internet und + versuchen es erneut! + + Keine Internetverbindung + Bitte verbinden Sie das Gerät mit dem Internet und versuchen + es erneut! + + + + Aufzeichnung starten + Aufzeichnung pausieren + Aufzeichnung stoppen + Aufzeichnung läuft + Fragebogen starten + Bitte beantworten Sie die Frage + Simpler Fragebogen + Es werden aktuell Daten aufgezeichnet + + Datenaufzeichnung ist bereit + Bitte öffnen sie die MORE app, um Daten + aufzuzeichnen + + Aufzeichnung öffnen + Datenpunkte aufgezeichnet + LimeSurvey starten + + + Wichtige Nachrichten + Du hast eine neue Erinnerung. \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/io/redlink/more/formatter/HealthConnectValueFormatterTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/formatter/HealthConnectValueFormatterTest.kt new file mode 100644 index 00000000..5986808b --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/formatter/HealthConnectValueFormatterTest.kt @@ -0,0 +1,82 @@ +package io.redlink.more.formatter + +import dev.icerock.moko.resources.desc.desc +import io.redlink.more.observations.healthConnect.HealthConnectDataType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class HealthConnectValueFormatterTest { + + @Test + fun `format returns null for non health connect observation types`() { + assertNull(HealthConnectValueFormatter.format("some-other-type", "{\"hr\":72}")) + } + + @Test + fun `format returns null when current value is not a string`() { + assertNull(HealthConnectValueFormatter.format(HealthConnectDataType.HEART_RATE.subTypeValue, 72)) + } + + @Test + fun `format extracts heart rate value from stored json`() { + val value = HealthConnectValueFormatter.format( + HealthConnectDataType.HEART_RATE.subTypeValue, + "{\"timestamp\":\"2026-01-01T00:00:00Z\",\"data\":{\"hr\":72}}" + ) + + assertEquals("72", value?.value) + } + + @Test + fun `format extracts step count value from stored json`() { + val value = HealthConnectValueFormatter.format( + HealthConnectDataType.STEPS.subTypeValue, + "{\"timestamp\":\"2026-01-01T00:00:00Z\",\"startTime\":\"2026-01-01T00:00:00Z\"," + + "\"endTime\":\"2026-01-01T00:00:00Z\",\"data\":{\"steps\":1000}}" + ) + + assertEquals("1000", value?.value) + } + + @Test + fun `format shows steps together with the goal when present`() { + val value = HealthConnectValueFormatter.format( + HealthConnectDataType.STEPS.subTypeValue, + "{\"timestamp\":\"2026-01-01T00:00:00Z\",\"data\":{\"steps\":1000,\"stepsGoal\":10000}}" + ) + + assertEquals("1000 / 10000", value?.value) + } + + @Test + fun `format resolves label and unit from the data type metadata`() { + val value = HealthConnectValueFormatter.format( + HealthConnectDataType.HEART_RATE.subTypeValue, + "{\"timestamp\":\"2026-01-01T00:00:00Z\",\"data\":{\"hr\":72}}" + ) + + assertEquals(HealthConnectDataType.HEART_RATE.unit.desc(), value?.unit) + assertEquals(HealthConnectDataType.HEART_RATE.label.desc(), value?.label) + } + + @Test + fun `format falls back to a dash when the value key is missing from the payload`() { + val value = HealthConnectValueFormatter.format( + HealthConnectDataType.STEPS.subTypeValue, + "{\"timestamp\":\"2026-01-01T00:00:00Z\",\"data\":{}}" + ) + + assertEquals("-", value?.value) + } + + @Test + fun `format returns null when the payload has no data object`() { + val value = HealthConnectValueFormatter.format( + HealthConnectDataType.HEART_RATE.subTypeValue, + "{\"timestamp\":\"2026-01-01T00:00:00Z\"}" + ) + + assertNull(value) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt index 9f632913..20ce809d 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt @@ -1,17 +1,26 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung 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/). - */ - package io.redlink.more.mocks import io.redlink.more.database.AppDatabase +import io.redlink.more.database.dao.AggregatedObservationDataDao +import io.redlink.more.database.dao.BaseDao +import io.redlink.more.database.dao.BluetoothDeviceDao +import io.redlink.more.database.dao.LatestObservationDataDao +import io.redlink.more.database.dao.NotificationDao +import io.redlink.more.database.dao.ObservationDao +import io.redlink.more.database.dao.ObservationDataDao +import io.redlink.more.database.dao.ScheduleDao +import io.redlink.more.database.dao.StudyDao +import io.redlink.more.database.entities.AggregatedObservationDataEntity +import io.redlink.more.database.entities.BluetoothDeviceEntity +import io.redlink.more.database.entities.LatestObservationDataEntity +import io.redlink.more.database.entities.NotificationEntity +import io.redlink.more.database.entities.ObservationDataEntity +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.entities.StudyEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map // NECESSARY!! DO NOT REMOVE! // This interface is a workaround to mock the room database, as there is a an issue within Room @@ -19,19 +28,29 @@ interface DB { fun clearAllTables() {} } -fun mockAppDatabase(): AppDatabase { +fun mockAppDatabase(): AppDatabase_Impl { return AppDatabase_Impl() } class AppDatabase_Impl : AppDatabase(), DB { - override fun studyDao() = TODO() - override fun scheduleDao() = TODO() - override fun observationDao() = TODO() - override fun observationDataDao() = TODO() - override fun notificationDao() = TODO() - override fun bluetoothDeviceDao() = TODO() + val studyDao = MockStudyDao() + val scheduleDao = MockScheduleDao() + val observationDao = MockObservationDao() + val observationDataDao = MockObservationDataDao() + val notificationDao = MockNotificationDao() + val bluetoothDeviceDao = MockBluetoothDeviceDao() + val aggregatedObservationDataDao = MockAggregatedObservationDataDao() + val latestObservationDataDao = MockLatestObservationDataDao() + + override fun studyDao() = studyDao + override fun scheduleDao() = scheduleDao + override fun observationDao() = observationDao + override fun observationDataDao() = observationDataDao + override fun notificationDao() = notificationDao + override fun bluetoothDeviceDao() = bluetoothDeviceDao + override fun aggregatedObservationDataDao() = aggregatedObservationDataDao + override fun latestObservationDataDao() = latestObservationDataDao override fun dataPointDao() = TODO() - override fun aggregatedObservationDataDao() = TODO() override fun createInvalidationTracker(): androidx.room.InvalidationTracker { return androidx.room.InvalidationTracker(this, emptyMap(), emptyMap(), "") @@ -39,6 +58,668 @@ class AppDatabase_Impl : AppDatabase(), DB { // DO NOT REMOVE THIS METHOD! IT IS NECESSARY FOR MOCKING THE DATABASE! override fun clearAllTables() { - super.clearAllTables() + // Simple implementation for fake + } +} + +open class MockBaseDao : BaseDao { + val items = mutableListOf() + var insertCallCount = 0 + var insertAllCallCount = 0 + var updateCallCount = 0 + var deleteCallCount = 0 + + override suspend fun insert(entity: T) { + insertCallCount++ + items.add(entity) + } + + override suspend fun insertAll(entities: List) { + insertAllCallCount++ + items.addAll(entities) + } + + override suspend fun update(entity: T) { + updateCallCount++ + /* simplistic */ + items.remove(entity) + items.add(entity) + } + + override suspend fun updateAll(entities: List) { + entities.forEach { update(it) } + } + + override suspend fun delete(entity: T) { + deleteCallCount++ + items.remove(entity) + } +} + +class MockStudyDao : MockBaseDao(), StudyDao { + private val studyFlow = MutableStateFlow(null) + var updateStudyStateCallCount = 0 + var deleteAllCallCount = 0 + + override suspend fun insert(entity: StudyEntity) { + super.insert(entity) + studyFlow.value = entity + } + + override suspend fun get(): StudyEntity? = items.lastOrNull() + override fun getFlow(): Flow = studyFlow + override suspend fun updateStudyState(studyId: String, state: String) { + updateStudyStateCallCount++ + val study = items.find { it.studyId == studyId } + if (study != null) { + val updated = study.copy(state = state) + items.remove(study) + items.add(updated) + studyFlow.value = updated + } + } + + override suspend fun deleteById(studyId: String) { + items.removeAll { it.studyId == studyId } + } + + override suspend fun deleteAll() { + deleteAllCallCount++ + items.clear() + studyFlow.value = null + } + + override fun getByIdFlow(studyId: String): Flow = + studyFlow.map { if (it?.studyId == studyId) it else null } + + override suspend fun getById(studyId: String): StudyEntity? = + items.find { it.studyId == studyId } + + override suspend fun getByActive(active: Boolean): List = + items.filter { it.active == active } + + override fun getByActiveFlow(active: Boolean): Flow> = TODO() + override suspend fun getByState(state: String): List = + items.filter { it.state == state } + + override fun getByStateFlow(state: String): Flow> = TODO() + override suspend fun getByParticipantId(participantId: Int): List = + items.filter { it.participantId == participantId } + + override suspend fun getActiveStudiesAtTime(timestamp: Long): List = TODO() + override fun getCount(): Flow = studyFlow.map { if (it != null) 1 else 0 } + override suspend fun getCountByActive(active: Boolean): Int = + items.count { it.active == active } +} + +class MockObservationDao : MockBaseDao(), ObservationDao { + override suspend fun insertAll(entities: List) { + insertAllCallCount++ + entities.forEach { entity -> + items.removeAll { it.observationId == entity.observationId } + items.add(entity) + } + } + + override suspend fun deleteById(id: String) { + items.removeAll { it.observationId == id } + } + + override suspend fun deleteByObservationId(observationId: String) { + items.removeAll { it.observationId == observationId } + } + + override suspend fun deleteAll() { + items.clear() + } + + override suspend fun getByObservationId(observationId: String): ObservationEntity? = + items.find { it.observationId == observationId } + + override fun getByObservationIdFlow(observationId: String): Flow = TODO() + + override suspend fun getAll(): List = items.toList() + override fun getAllFlow(): Flow> = TODO() + override suspend fun getByObservationType(observationType: String): List = + items.filter { it.observationType == observationType } + + override fun getByObservationTypeFlow(observationType: String): Flow> = + TODO() + + override suspend fun getByHidden(hidden: Boolean): List = TODO() + override fun getByHiddenFlow(hidden: Boolean): Flow> = TODO() + override suspend fun getByScheduleLess(scheduleLess: Boolean): List = TODO() + override fun getByScheduleLessFlow(scheduleLess: Boolean): Flow> = + TODO() + + override suspend fun getByRequired(required: Boolean): List = TODO() + override fun getByRequiredFlow(required: Boolean): Flow> = TODO() + override suspend fun getByVersion(version: Long): List = TODO() + override suspend fun getByTimeRange( + fromTimestamp: Long, + toTimestamp: Long + ): List = TODO() + + override fun getByTimeRangeFlow( + fromTimestamp: Long, + toTimestamp: Long + ): Flow> = TODO() + + override suspend fun searchByTitle(searchTerm: String): List = TODO() + override suspend fun searchByParticipantInfo(searchTerm: String): List = + TODO() + + override suspend fun getCount(): Int = items.size + override suspend fun getCountByType(observationType: String): Int = + items.count { it.observationType == observationType } + + override suspend fun getCountByRequired(required: Boolean): Int = + items.count { it.required == required } + + override suspend fun getAllObservationTypes(): List = + items.map { it.observationType }.distinct() + + override suspend fun updateVersion(observationId: String, version: Long) { + val obs = items.find { it.observationId == observationId } + if (obs != null) { + items.remove(obs) + items.add(obs.copy(version = version)) + } + } + + override suspend fun updateHidden(observationId: String, hidden: Boolean) { + val obs = items.find { it.observationId == observationId } + if (obs != null) { + items.remove(obs) + items.add(obs.copy(hidden = hidden)) + } + } +} + +class MockScheduleDao : MockBaseDao(), ScheduleDao { + var updateStateCallCount = 0 + + private val itemsFlow = MutableStateFlow>(emptyList()) + + override suspend fun insert(entity: ScheduleEntity) { + super.insert(entity) + itemsFlow.value = items.toList() + } + + override suspend fun insertAll(entities: List) { + insertAllCallCount++ + entities.forEach { entity -> + items.removeAll { it.scheduleId == entity.scheduleId } + items.add(entity) + } + itemsFlow.value = items.toList() + } + + override suspend fun deleteAll() { + items.clear() + itemsFlow.value = items.toList() + } + + override suspend fun deleteById(scheduleId: String) { + items.removeAll { it.scheduleId == scheduleId } + itemsFlow.value = items.toList() + } + + override suspend fun deleteByObservationId(observationId: String) { + items.removeAll { it.observationId == observationId } + itemsFlow.value = items.toList() + } + + override fun getById(scheduleId: String): Flow = + itemsFlow.map { list -> list.find { it.scheduleId == scheduleId } } + + override fun getByIdFlow(scheduleId: String): Flow = + itemsFlow.map { list -> list.find { it.scheduleId == scheduleId } } + + override suspend fun getAll(): List = items.toList() + override fun getAllFlow(): Flow> = itemsFlow + override suspend fun getByObservationId(observationId: String): List = + items.filter { it.observationId == observationId } + + override fun getByObservationIdFlow(observationId: String): Flow> = TODO() + override suspend fun getByObservationType(observationType: String): List = + TODO() + + override fun getByObservationTypeFlow(observationType: String): Flow> = + TODO() + + override suspend fun getByDone(done: Boolean): List = + items.filter { it.done == done } + + override fun getByDoneFlow(done: Boolean): Flow> = TODO() + override fun getByStatesFlow(states: List): Flow> = TODO() + + override suspend fun getByHidden(hidden: Boolean): List = TODO() + override fun getByHiddenFlow(hidden: Boolean): Flow> = TODO() + override suspend fun getByState(state: String): List = + items.filter { it.state == state } + + override fun getByStateFlow(state: String): Flow> = TODO() + override fun getSchedulesWithReminder( + states: List, + minTimestamp: Long, + maxTimestamp: Long, + limit: Int + ): Flow> = TODO() + + override suspend fun getActiveSchedulesAtTime(timestamp: Long): List = TODO() + override suspend fun getAvailableSchedules(currentTime: Long): List = TODO() + override fun getAvailableSchedulesFlow(currentTime: Long): Flow> = TODO() + override suspend fun getExpiredSchedules(timestamp: Long): List = TODO() + override suspend fun getCount(): Int = items.size + override fun countAsFlow(): Flow = TODO() + override suspend fun getCountByDone(done: Boolean): Int = + items.count { it.done == done } + + override suspend fun getCountByObservationId(observationId: String): Int = + items.count { it.observationId == observationId } + + override fun getObservationTypesForScheduleIds(scheduleIds: Set): Flow> = + TODO() + + override suspend fun updateDoneStatus(scheduleId: String, done: Boolean) { + val sch = items.find { it.scheduleId == scheduleId } + if (sch != null) { + items.remove(sch) + items.add(sch.copy(done = done)) + itemsFlow.value = items.toList() + } + } + + override suspend fun updateState(scheduleId: String, state: String) { + updateStateCallCount++ + val sch = items.find { it.scheduleId == scheduleId } + if (sch != null) { + items.remove(sch) + items.add(sch.copy(state = state)) + itemsFlow.value = items.toList() + } + } +} + +class MockNotificationDao : MockBaseDao(), NotificationDao { + private val _itemsFlow = MutableStateFlow>(emptyList()) + + override suspend fun insert(entity: NotificationEntity) { + super.insert(entity) + _itemsFlow.value = items.toList() + } + + override suspend fun insertAll(entities: List) { + super.insertAll(entities) + _itemsFlow.value = items.toList() + } + + override suspend fun deleteById(notificationId: String) { + items.removeAll { it.notificationId == notificationId } + _itemsFlow.value = items.toList() } -} \ No newline at end of file + + override suspend fun deleteByChannelId(channelId: String) { + items.removeAll { it.channelId == channelId } + _itemsFlow.value = items.toList() + } + + override suspend fun deleteAll() { + items.clear() + _itemsFlow.value = items.toList() + } + + override suspend fun getById(notificationId: String): NotificationEntity? = + items.find { it.notificationId == notificationId } + + override fun getByIdFlow(notificationId: String): Flow = + _itemsFlow.map { list -> list.find { it.notificationId == notificationId } } + + override suspend fun getAll(): List = items.toList() + override fun getAllFlow(): Flow> = _itemsFlow + + override suspend fun getByChannelId(channelId: String): List = + items.filter { it.channelId == channelId } + + override fun getByChannelIdFlow(channelId: String): Flow> = + _itemsFlow.map { list -> list.filter { it.channelId == channelId } } + + override suspend fun getByReadStatus(read: Boolean): List = + items.filter { it.read == read } + + override fun getByReadStatusFlow(read: Boolean): Flow> = + _itemsFlow.map { list -> list.filter { it.read == read } } + + override suspend fun getByCompletedStatus(completed: Boolean): List = + items.filter { it.completed == completed } + + override fun getByCompletedStatusFlow(completed: Boolean): Flow> = + _itemsFlow.map { list -> list.filter { it.completed == completed } } + + override suspend fun getByUserFacing(userFacing: Boolean): List = + items.filter { it.userFacing == userFacing } + + override fun getByUserFacingFlow(userFacing: Boolean): Flow> = + _itemsFlow.map { list -> list.filter { it.userFacing == userFacing } } + + override suspend fun getByPriority(priority: Long): List = + items.filter { it.priority == priority } + + override fun getByPriorityFlow(priority: Long): Flow> = + _itemsFlow.map { list -> list.filter { it.priority == priority } } + + override suspend fun getByMinPriority(minPriority: Long): List = + items.filter { it.priority >= minPriority } + + override fun getByMinPriorityFlow(minPriority: Long): Flow> = + _itemsFlow.map { list -> list.filter { it.priority >= minPriority } } + + override suspend fun getByTimeRange( + fromTimestamp: Long, + toTimestamp: Long + ): List = + items.filter { it.timestamp != null && it.timestamp >= fromTimestamp && it.timestamp <= toTimestamp } + + override fun getByTimeRangeFlow( + fromTimestamp: Long, + toTimestamp: Long + ): Flow> = + _itemsFlow.map { list -> list.filter { it.timestamp != null && it.timestamp >= fromTimestamp && it.timestamp <= toTimestamp } } + + override suspend fun getWithDeepLink(): List = + items.filter { it.deepLink != null && it.deepLink.isNotEmpty() } + + override fun getWithDeepLinkFlow(): Flow> = + _itemsFlow.map { list -> list.filter { it.deepLink != null && it.deepLink.isNotEmpty() } } + + override suspend fun searchByContent(searchTerm: String): List = + items.filter { it.toString().contains(searchTerm, ignoreCase = true) } + + override fun searchByContentFlow(searchTerm: String): Flow> = + _itemsFlow.map { list -> + list.filter { + it.toString().contains(searchTerm, ignoreCase = true) + } + } + + override suspend fun getLatest(limit: Int): List = + items.sortedByDescending { it.timestamp }.take(limit) + + override fun getByPastUserFacingFlow(userFacing: Boolean): Flow> = + _itemsFlow.map { list -> list.filter { it.userFacing == userFacing } } + + override fun getUnreadUserFacingFromPastFlow(): Flow = + _itemsFlow.map { list -> list.count { !it.read && it.userFacing } } + + override suspend fun getScheduledNotificationCount(currentTimestamp: Long): Int = + items.count { (it.timestamp ?: 0) > currentTimestamp } + + override suspend fun getScheduledNotifications(currentTimestamp: Long): List = + items.filter { (it.timestamp ?: 0) > currentTimestamp } + + override fun getLatestFlow(limit: Int): Flow> = + _itemsFlow.map { list -> list.sortedByDescending { it.timestamp }.take(limit) } + + override suspend fun getLatestUserFacing(limit: Int): List = + items.filter { it.userFacing }.sortedByDescending { it.timestamp }.take(limit) + + override fun getLatestUserFacingFlow(limit: Int): Flow> = + _itemsFlow.map { list -> + list.filter { it.userFacing }.sortedByDescending { it.timestamp }.take(limit) + } + + override suspend fun getUnreadUserFacing(): List = + items.filter { !it.read && it.userFacing }.sortedByDescending { it.priority } + .sortedByDescending { it.timestamp } + + override fun getUnreadUserFacingFlow(): Flow> = + _itemsFlow.map { list -> + list.filter { !it.read && it.userFacing }.sortedByDescending { it.priority } + .sortedByDescending { it.timestamp } + } + + override suspend fun getAllOrderedByPriorityAndTime(): List = + items.sortedByDescending { it.priority }.sortedByDescending { it.timestamp } + + override fun getAllOrderedByPriorityAndTimeFlow(): Flow> = + _itemsFlow.map { list -> + list.sortedByDescending { it.priority }.sortedByDescending { it.timestamp } + } + + override fun getCount(): Flow = _itemsFlow.map { it.size.toLong() } + + override suspend fun getCountByReadStatus(read: Boolean): Int = + items.count { it.read == read } + + override suspend fun getCountByCompletedStatus(completed: Boolean): Int = + items.count { it.completed == completed } + + override fun getCountByUserFacing(userFacing: Boolean): Flow = + _itemsFlow.map { list -> list.count { it.userFacing == userFacing }.toLong() } + + override suspend fun getUnreadUserFacingCount(): Int = + items.count { !it.read && it.userFacing } + + override fun getUnreadUserFacingCountFlow(): Flow = + _itemsFlow.map { list -> list.count { !it.read && it.userFacing } } + + override suspend fun getAllChannelIds(): List = + items.mapNotNull { it.channelId }.distinct() + + override suspend fun updateReadStatus(notificationId: String, read: Boolean) { + val index = items.indexOfFirst { it.notificationId == notificationId } + if (index != -1) { + items[index] = items[index].copy(read = read) + _itemsFlow.value = items.toList() + } + } + + override suspend fun updateCompletedStatus(notificationId: String, completed: Boolean) { + val index = items.indexOfFirst { it.notificationId == notificationId } + if (index != -1) { + items[index] = items[index].copy(completed = completed) + _itemsFlow.value = items.toList() + } + } + + override suspend fun markAllAsReadByChannelId(channelId: String) { + items.forEachIndexed { index, notificationEntity -> + if (notificationEntity.channelId == channelId) { + items[index] = notificationEntity.copy(read = true) + } + } + _itemsFlow.value = items.toList() + } + + override suspend fun markAllAsRead() { + items.forEachIndexed { index, notificationEntity -> + items[index] = notificationEntity.copy(read = true) + } + _itemsFlow.value = items.toList() + } + + override suspend fun deleteOlderThan(timestamp: Long): Int { + val toRemove = items.filter { (it.timestamp ?: 0) < timestamp } + items.removeAll(toRemove) + _itemsFlow.value = items.toList() + return toRemove.size + } + + override suspend fun deleteOldReadAndCompleted(timestamp: Long): Int { + val toRemove = items.filter { it.read && it.completed && (it.timestamp ?: 0) < timestamp } + items.removeAll(toRemove) + _itemsFlow.value = items.toList() + return toRemove.size + } +} + +class MockObservationDataDao : ObservationDataDao { + override suspend fun insert(entity: ObservationDataEntity) = TODO() + override suspend fun insertAll(entities: List) = TODO() + override suspend fun update(entity: ObservationDataEntity) = TODO() + override suspend fun updateAll(entities: List) = TODO() + override suspend fun delete(entity: ObservationDataEntity) = TODO() + override suspend fun deleteByObservationId(observationId: String) {} + override suspend fun deleteAll() {} + override suspend fun getAll(): List = TODO() + override fun getAllFlow(): Flow> = TODO() + override suspend fun getByObservationId(observationId: String): List = + TODO() + + override fun getByObservationIdFlow(observationId: String): Flow> = + TODO() + + override suspend fun deleteById(dataId: String) = TODO() + override suspend fun deleteByObservationType(observationType: String) = TODO() + override suspend fun getById(dataId: String): ObservationDataEntity? = TODO() + override fun getByIdFlow(dataId: String): Flow = TODO() + override suspend fun getByObservationType(observationType: String): List = + TODO() + + override fun getByObservationTypeFlow(observationType: String): Flow> = + TODO() + + override suspend fun getByObservationIdAndType( + observationId: String, + observationType: String + ): List = TODO() + + override fun getByObservationIdAndTypeFlow( + observationId: String, + observationType: String + ): Flow> = TODO() + + override suspend fun getByTimeRange( + fromTimestamp: Long, + toTimestamp: Long + ): List = TODO() + + override fun getByTimeRangeFlow( + fromTimestamp: Long, + toTimestamp: Long + ): Flow> = TODO() + + override suspend fun getByObservationIdAndTimeRange( + observationId: String, + fromTimestamp: Long, + toTimestamp: Long + ): List = TODO() + + override fun getByObservationIdAndTimeRangeFlow( + observationId: String, + fromTimestamp: Long, + toTimestamp: Long + ): Flow> = TODO() + + override suspend fun getByObservationTypeAndTimeRange( + observationType: String, + fromTimestamp: Long, + toTimestamp: Long + ): List = TODO() + + override fun getByObservationTypeAndTimeRangeFlow( + observationType: String, + fromTimestamp: Long, + toTimestamp: Long + ): Flow> = TODO() + + override suspend fun getFromTimestamp(timestamp: Long): List = TODO() + override fun getFromTimestampFlow(timestamp: Long): Flow> = TODO() + override suspend fun getUpToTimestamp(timestamp: Long): List = TODO() + override fun getUpToTimestampFlow(timestamp: Long): Flow> = TODO() + override suspend fun getLatest(limit: Int): List = TODO() + override fun getLatestFlow(limit: Int): Flow> = TODO() + override suspend fun getLatestByObservationId( + observationId: String, + limit: Int + ): List = TODO() + + override fun getLatestByObservationIdFlow( + observationId: String, + limit: Int + ): Flow> = TODO() + + override suspend fun getCount(): Int = TODO() + override suspend fun getCountByObservationId(observationId: String): Int = TODO() + override suspend fun getCountByObservationType(observationType: String): Int = TODO() + override suspend fun getCountByTimeRange(fromTimestamp: Long, toTimestamp: Long): Int = TODO() + override suspend fun getAllObservationTypes(): List = TODO() + override suspend fun getAllObservationIds(): List = TODO() + override suspend fun getEarliestTimestamp(): Long? = TODO() + override suspend fun getLatestTimestamp(): Long? = TODO() + override suspend fun deleteOlderThan(timestamp: Long): Int = TODO() + override suspend fun deleteOlderThanByObservationId( + observationId: String, + timestamp: Long + ): Int = TODO() +} + +class MockLatestObservationDataDao : LatestObservationDataDao { + val items = MutableStateFlow>(emptyMap()) + + override suspend fun upsert(data: LatestObservationDataEntity) { + items.value += (data.scheduleId to data) + } + + override fun getByScheduleId(scheduleId: String): Flow = + items.map { it[scheduleId] } + + override suspend fun getLatestByObservationType(observationType: String): LatestObservationDataEntity? = + items.value.values.filter { it.observationType == observationType } + .maxByOrNull { it.timestamp } + + override suspend fun deleteByScheduleId(scheduleId: String) { + items.value -= scheduleId + } + + override suspend fun deleteAll() { + items.value = emptyMap() + } +} + +class MockAggregatedObservationDataDao : AggregatedObservationDataDao { + override suspend fun insert(entity: AggregatedObservationDataEntity) = TODO() + override suspend fun insertAll(entities: List) = TODO() + override suspend fun update(entity: AggregatedObservationDataEntity) = TODO() + override suspend fun updateAll(entities: List) = TODO() + override suspend fun delete(entity: AggregatedObservationDataEntity) = TODO() + override suspend fun deleteByObservationId(observationId: String) {} + override suspend fun deleteAll() {} + override suspend fun getById(id: String): AggregatedObservationDataEntity? = TODO() + override fun getByIdFlow(id: String): Flow = TODO() + override suspend fun getByObservationId(observationId: String): List = + TODO() + + override fun getByObservationIdFlow(observationId: String): Flow> = + TODO() + + override suspend fun getByObservationType(observationType: String): List = + TODO() + + override fun getByObservationTypeFlow(observationType: String): Flow> = + TODO() + + override suspend fun getAll(): List = TODO() + override fun getAllFlow(): Flow> = TODO() + override suspend fun deleteById(id: String) = TODO() + override suspend fun getCount(): Int = TODO() + override suspend fun getCountByObservationId(observationId: String): Int = TODO() +} + +class MockBluetoothDeviceDao : BluetoothDeviceDao { + override suspend fun insert(entity: BluetoothDeviceEntity) = TODO() + override suspend fun insertAll(entities: List) = TODO() + override suspend fun update(entity: BluetoothDeviceEntity) = TODO() + override suspend fun updateAll(entities: List) = TODO() + override suspend fun delete(entity: BluetoothDeviceEntity) = TODO() + override suspend fun getAll(): List = TODO() + override fun getAllFlow(): Flow> = TODO() + override suspend fun getByAddress(address: String): BluetoothDeviceEntity? = TODO() + override fun getByAddressFlow(address: String): Flow = TODO() + override suspend fun getAllAddresses(): List = TODO() + override fun getAllAddressesFlow(): Flow> = TODO() + override suspend fun deleteByAddress(address: String) = TODO() + override suspend fun getCount(): Int = TODO() + override suspend fun deleteAll() = TODO() +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt index 5664a960..39099455 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt @@ -15,6 +15,7 @@ import io.ktor.utils.io.core.Closeable import io.redlink.more.database.entities.AggregatedObservationDataEntity import io.redlink.more.database.entities.BluetoothDeviceEntity import io.redlink.more.database.entities.DataPointEntity +import io.redlink.more.database.entities.LatestObservationDataEntity import io.redlink.more.database.entities.NotificationEntity import io.redlink.more.database.entities.ObservationDataEntity import io.redlink.more.database.entities.ObservationEntity @@ -248,6 +249,24 @@ class MockObservationRepository : ObservationRepository { override suspend fun getObservationByObservationId(observationId: String): ObservationEntity? = observations.value[observationId] + + fun addObservations(observations: List) { + observations.forEach { storeObservation(it) } + } + + private val latestDataPoints = + MutableStateFlow>(emptyMap()) + + override suspend fun storeLatestDataPoint(data: LatestObservationDataEntity) { + latestDataPoints.value += (data.scheduleId to data) + } + + override fun latestDataPointForSchedule(scheduleId: String): Flow = + latestDataPoints.map { it[scheduleId] } + + override suspend fun latestDataPointTimestamp(observationType: String): Long? = + latestDataPoints.value.values.filter { it.observationType == observationType } + .maxByOrNull { it.timestamp }?.timestamp } class MockScheduleRepository : ScheduleRepository { @@ -267,6 +286,10 @@ class MockScheduleRepository : ScheduleRepository { _schedules.value += (schedule.scheduleId to schedule) } + fun addSchedules(schedules: List) { + schedules.forEach { storeSchedule(it) } + } + override fun count(): Flow = _schedules.map { it.size } override fun allSchedulesWithStatus(done: Boolean): Flow> = diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/ObservationManagerTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/ObservationManagerTest.kt index 094c5eb8..2f49207e 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/observations/ObservationManagerTest.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/ObservationManagerTest.kt @@ -1,14 +1,18 @@ package io.redlink.more.observations +import io.redlink.more.SharedRes import io.redlink.more.database.entities.ObservationEntity import io.redlink.more.database.entities.ScheduleEntity import io.redlink.more.database.repository.MainRepository +import io.redlink.more.dialog.AlertController +import io.redlink.more.extensions.desc import io.redlink.more.mocks.MockMainRepository import io.redlink.more.mocks.MockObservationFactory import io.redlink.more.mocks.MockStudyMoreScope import io.redlink.more.models.ScheduleState import io.redlink.more.observations.observationTypes.ObservationType import io.redlink.more.scopes.MoreDispatchers +import io.redlink.more.services.store.PermissionApprovalState import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -63,6 +67,7 @@ class ObservationManagerTest { fun tearDown() { testScope.cancel() Dispatchers.resetMain() + Observation.resetRequestedPermissions() } @Test @@ -296,6 +301,60 @@ class ObservationManagerTest { assertEquals(1000L, mockObservation.lastStoreStart) } + @Test + fun testUpdateObservationPermissionsRequestsPermissionWhenNotSet() = runTest { + val mockObservation = + MockObservation(repository, ObservationType("simple-observation", emptySet())) + val permissionObserver = FakePermissionObserver(PermissionApprovalState.NOT_SET) + mockObservation.setPermissionObserver(permissionObserver) + + mockObservation.updateObservationPermissions() + + assertEquals(1, permissionObserver.requestPermissionCallCount) + } + + @Test + fun testUpdateObservationPermissionsShowsAlertWhenDeclined() = runTest { + val mockObservation = + MockObservation(repository, ObservationType("simple-observation", emptySet())) + val permissionObserver = FakePermissionObserver(PermissionApprovalState.DECLINED) + mockObservation.setPermissionObserver(permissionObserver) + + mockObservation.updateObservationPermissions() + + assertEquals( + SharedRes.strings.observation_permission_missing_title.desc(), + AlertController.alertDialogModel.value?.title + ) + assertEquals(0, permissionObserver.requestPermissionCallCount) + AlertController.closeAlertDialog() + } + + @Test + fun testUpdateObservationPermissionsDoesNothingWhenGranted() = runTest { + val mockObservation = + MockObservation(repository, ObservationType("simple-observation", emptySet())) + val permissionObserver = FakePermissionObserver(PermissionApprovalState.GRANTED) + mockObservation.setPermissionObserver(permissionObserver) + val alertBefore = AlertController.alertDialogModel.value + + mockObservation.updateObservationPermissions() + + assertEquals(0, permissionObserver.requestPermissionCallCount) + assertEquals(alertBefore, AlertController.alertDialogModel.value) + } + + class FakePermissionObserver(private val state: PermissionApprovalState) : + ObservationPermissionObserver { + var requestPermissionCallCount = 0 + + override fun requestPermission(observationType: ObservationType) { + requestPermissionCallCount++ + } + + override fun permissionState(observationType: ObservationType): PermissionApprovalState = state + } + class MockDataRecorder : DataRecorder { var lastStartedScheduleId: String? = null var lastStartedMultipleScheduleIds: Set? = null @@ -348,7 +407,7 @@ class ObservationManagerTest { // } - override fun start( + override suspend fun start( observationId: String, scheduleId: String, notificationId: String? diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/accelerometer/BackgroundAccelerometerObservationTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/accelerometer/BackgroundAccelerometerObservationTest.kt new file mode 100644 index 00000000..9ba8860a --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/accelerometer/BackgroundAccelerometerObservationTest.kt @@ -0,0 +1,101 @@ +package io.redlink.more.observations.accelerometer + +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.entities.StudyEntity +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.mockObservationDataManager +import io.redlink.more.models.ScheduleState +import io.redlink.more.observations.ObservationBulkModel +import io.redlink.more.observations.observationTypes.AccelerometerType +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class BackgroundAccelerometerObservationTest { + + private val accType = AccelerometerType(emptySet()).observationType + + private class FakeCollector( + override val isRecordingAvailable: Boolean = true, + private val samples: List = emptyList() + ) : BackgroundAccelerometerCollector { + var collectCallCount = 0 + var lastCollectRange: Pair? = null + val recordedDurations = mutableListOf() + + override fun record(durationSeconds: Double) { + recordedDurations.add(durationSeconds) + } + + override suspend fun collect(from: Instant, to: Instant): List { + collectCallCount++ + lastCollectRange = from to to + return samples + } + } + + @Test + fun testCollectAllDataCollectsForScheduleActiveSinceStudyStart() = runTest { + val repository = MockMainRepository() + val studyStart = Clock.System.now() - 2.hours + repository.mockStudy.upsert(StudyEntity(start = studyStart.epochSeconds)) + repository.mockObservation.addObservations( + listOf(ObservationEntity(observationId = "obs-1", observationType = accType)) + ) + repository.mockSchedule.addSchedules( + listOf( + ScheduleEntity( + scheduleId = "sched-1", + observationId = "obs-1", + start = studyStart.epochSeconds, + state = ScheduleState.ACTIVE.name + ) + ) + ) + val collector = FakeCollector() + val observation = BackgroundAccelerometerObservation(repository, emptySet(), collector) + observation.applyDataManager(mockObservationDataManager(repository)) + + observation.collectAllData() + + assertEquals(1, collector.collectCallCount) + assertEquals(Instant.fromEpochSeconds(studyStart.epochSeconds), collector.lastCollectRange?.first) + } + + @Test + fun testComputeRecordDurationDerivesFromTaskWindow() { + val now = Clock.System.now() + val duration = BackgroundAccelerometerObservation.computeRecordDuration( + now, now + 5.hours, now + ) + assertEquals((5.hours).inWholeSeconds.toDouble(), duration) + } + + @Test + fun testComputeRecordDurationFallsBackToDefaultWhenWindowAlreadyElapsed() { + val now = Clock.System.now() + val duration = BackgroundAccelerometerObservation.computeRecordDuration( + now - 2.hours, now - 1.hours, now + ) + assertEquals(60.0 * 10, duration) + } + + @Test + fun testComputeReArmDurationReturnsRemainingWindow() { + val now = Clock.System.now() + val duration = BackgroundAccelerometerObservation.computeReArmDuration(now + 3.hours, now) + assertEquals((3.hours).inWholeSeconds.toDouble(), duration) + } + + @Test + fun testComputeReArmDurationReturnsNullWhenWindowIsOver() { + val now = Clock.System.now() + assertNull(BackgroundAccelerometerObservation.computeReArmDuration(now - 1.hours, now)) + assertNull(BackgroundAccelerometerObservation.computeReArmDuration(null, now)) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt index 0e93e07a..daeb83eb 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt @@ -17,6 +17,7 @@ import io.redlink.more.mocks.mockObservationDataManager import io.redlink.more.observations.appUsage.model.LogEvent import io.redlink.more.services.store.PermissionRepositoryImpl import io.redlink.more.services.store.PermissionType +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -24,14 +25,14 @@ import kotlin.test.assertTrue class AppUsageObservationTest { @Test - fun testInstantEvent() { + fun testInstantEvent() = runTest { val mockRepo = MockMainRepository() val mockSharedStorage = MockSharedStorageRepository() val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) permissionRepo.updatePermission(PermissionType.APP_TRACKING, true) val observation = AppUsageObservation(mockRepo, permissionRepo) - observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.applyDataManager(mockObservationDataManager(mockRepo)) observation.start("1", "1") val event = LogEvent.URL_OPEN @@ -45,14 +46,14 @@ class AppUsageObservationTest { } @Test - fun testRangeEvent() { + fun testRangeEvent() = runTest { val mockRepo = MockMainRepository() val mockSharedStorage = MockSharedStorageRepository() val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) permissionRepo.updatePermission(PermissionType.APP_TRACKING, true) val observation = AppUsageObservation(mockRepo, permissionRepo) - observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.applyDataManager(mockObservationDataManager(mockRepo)) observation.start("1", "1") observation.onEvent(LogEvent.VIEW_OPEN, "test_view") @@ -67,14 +68,14 @@ class AppUsageObservationTest { } @Test - fun testTrackingDeclined() { + fun testTrackingDeclined() = runTest { val mockRepo = MockMainRepository() val mockSharedStorage = MockSharedStorageRepository() val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) val observation = AppUsageObservation(mockRepo, permissionRepo) - observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.applyDataManager(mockObservationDataManager(mockRepo)) observation.start("1", "1") observation.onEvent(LogEvent.URL_OPEN, "https://example.com") @@ -82,14 +83,14 @@ class AppUsageObservationTest { } @Test - fun testStoreWithoutApproval() { + fun testStoreWithoutApproval() = runTest { val mockRepo = MockMainRepository() val mockSharedStorage = MockSharedStorageRepository() val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) val observation = AppUsageObservation(mockRepo, permissionRepo) - observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.applyDataManager(mockObservationDataManager(mockRepo)) observation.start("1", "1") // BUTTON_PRESS has storeWithoutApproval = true now @@ -103,14 +104,14 @@ class AppUsageObservationTest { } @Test - fun testSendAfterApproval() { + fun testSendAfterApproval() = runTest { val mockRepo = MockMainRepository() val mockSharedStorage = MockSharedStorageRepository() val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) val observation = AppUsageObservation(mockRepo, permissionRepo) - observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.applyDataManager(mockObservationDataManager(mockRepo)) observation.start("1", "1") // VIEW_OPEN has storeWithoutApproval = false @@ -130,7 +131,7 @@ class AppUsageObservationTest { } @Test - fun testPersistenceAcrossRestarts() { + fun testPersistenceAcrossRestarts() = runTest { val mockRepo = MockMainRepository() val mockSharedStorage = MockSharedStorageRepository() val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) @@ -138,7 +139,7 @@ class AppUsageObservationTest { // First session: Tracking is declined, we log an event val observation1 = AppUsageObservation(mockRepo, permissionRepo) - observation1.setDataManager(mockObservationDataManager(mockRepo)) + observation1.applyDataManager(mockObservationDataManager(mockRepo)) observation1.start("1", "1") observation1.onEvent(LogEvent.URL_OPEN, "https://example.com/buffered") @@ -148,7 +149,7 @@ class AppUsageObservationTest { // Restart session: Tracking is still declined, we log another event val observation2 = AppUsageObservation(mockRepo, permissionRepo) - observation2.setDataManager(mockObservationDataManager(mockRepo)) + observation2.applyDataManager(mockObservationDataManager(mockRepo)) observation2.start("1", "1") observation2.onEvent(LogEvent.URL_OPEN, "https://example.com/buffered2") @@ -171,14 +172,14 @@ class AppUsageObservationTest { } @Test - fun testBufferUntilStart() { + fun testBufferUntilStart() = runTest { val mockRepo = MockMainRepository() val mockSharedStorage = MockSharedStorageRepository() val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) permissionRepo.updatePermission(PermissionType.APP_TRACKING, true) val observation = AppUsageObservation(mockRepo, permissionRepo) - observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.applyDataManager(mockObservationDataManager(mockRepo)) // No start("1", "1") yet! @@ -197,14 +198,14 @@ class AppUsageObservationTest { } @Test - fun testStoreWithoutApprovalBufferedUntilStart() { + fun testStoreWithoutApprovalBufferedUntilStart() = runTest { val mockRepo = MockMainRepository() val mockSharedStorage = MockSharedStorageRepository() val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) // Declined val observation = AppUsageObservation(mockRepo, permissionRepo) - observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.applyDataManager(mockObservationDataManager(mockRepo)) observation.onEvent(LogEvent.APP_TRACKING_ACCEPTED, "") @@ -220,14 +221,14 @@ class AppUsageObservationTest { } @Test - fun testOnStudyExitClearsBuffer() { + fun testOnStudyExitClearsBuffer() = runTest { val mockRepo = MockMainRepository() val mockSharedStorage = MockSharedStorageRepository() val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) permissionRepo.updatePermission(PermissionType.APP_TRACKING, true) val observation = AppUsageObservation(mockRepo, permissionRepo) - observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.applyDataManager(mockObservationDataManager(mockRepo)) // Buffer an event (by not starting observation) observation.onEvent(LogEvent.URL_OPEN, "https://redlink.at") diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservationTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservationTest.kt new file mode 100644 index 00000000..32fa8671 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservationTest.kt @@ -0,0 +1,468 @@ +package io.redlink.more.observations.healthConnect + +import io.redlink.more.SharedRes +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.entities.StudyEntity +import io.redlink.more.dialog.AlertController +import io.redlink.more.extensions.desc +import io.redlink.more.extensions.jsonRead +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockSharedStorageRepository +import io.redlink.more.mocks.mockObservationDataManager +import io.redlink.more.models.ScheduleState +import io.redlink.more.observations.Observation +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.healthConnect.model.HealthConnectSample +import io.redlink.more.observations.polling.PollingObservationRegistry +import io.redlink.more.observations.polling.PollingTaskScheduler +import io.redlink.more.services.store.PermissionApprovalState +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +class HealthConnectObservationTest { + + private val heartRateType = HealthConnectDataType.HEART_RATE.subTypeValue + private val stepsType = HealthConnectDataType.STEPS.subTypeValue + + private class FakeCollector( + override val dataType: HealthConnectDataType, + private val permission: PermissionApprovalState = PermissionApprovalState.GRANTED, + private val samples: List = emptyList(), + private val distanceInMeters: Double? = null + ) : HealthConnectCollector { + var collectCallCount = 0 + var requestPermissionCallCount = 0 + + override suspend fun permissionState(): PermissionApprovalState = permission + + override suspend fun requestPermission() { + requestPermissionCallCount++ + } + + override suspend fun collect(from: Instant, to: Instant): List { + collectCallCount++ + return samples + } + + override suspend fun collectDistanceInMeters(from: Instant, to: Instant): Double? = + distanceInMeters + } + + /** + * [Observation.collectAllData] resolves its collection window from the study's start when no + * poll has happened yet ([Observation.getLastCollectionTimestamp]) - without this, registerRecentSchedules() + * silently no-ops and nothing is ever collected. + */ + private suspend fun MockMainRepository.withStudyStart(): MockMainRepository = apply { + mockStudy.upsert(StudyEntity(start = (Clock.System.now() - 2.hours).epochSeconds)) + } + + @Test + fun testSingleInstanceRegisteredForBothSubtypes() { + val repository = MockMainRepository() + val factory = object : ObservationFactory( + repository, + MockSharedStorageRepository(), + mockObservationDataManager(repository) + ) { + init { + registerObservation { + HealthConnectObservation( + repository, + listOf( + FakeCollector(HealthConnectDataType.HEART_RATE), + FakeCollector(HealthConnectDataType.STEPS) + ) + ) + } + } + } + + factory.addNeededObservationTypes(setOf(heartRateType, stepsType)) + + val healthConnectInstances = factory.observations.filterIsInstance() + assertEquals(1, healthConnectInstances.size) + assertTrue( + factory.observationTypes() + .contains(HealthConnectObservationType().observationType) + ) + } + + @Test + fun testNoActiveCollectorsMeansNoCollectorCalls() = runTest { + val repository = MockMainRepository() + val heartRateCollector = FakeCollector(HealthConnectDataType.HEART_RATE) + val stepsCollector = FakeCollector(HealthConnectDataType.STEPS) + + val observation = HealthConnectObservation(repository, listOf(heartRateCollector, stepsCollector)) + observation.applyDataManager(mockObservationDataManager(repository)) + + observation.collectFromActiveCollectors() + + assertEquals(0, heartRateCollector.collectCallCount) + assertEquals(0, stepsCollector.collectCallCount) + } + + @Test + fun testCheckRequiredCollectorPermissionsShowsAlertOnDeclinedPermission() = runTest { + val repository = MockMainRepository().withStudyStart() + val heartRateCollector = + FakeCollector(HealthConnectDataType.HEART_RATE, permission = PermissionApprovalState.DECLINED) + val observation = HealthConnectObservation(repository, listOf(heartRateCollector)) + observation.applyDataManager(mockObservationDataManager(repository)) + + repository.mockObservation.addObservations( + listOf(ObservationEntity(observationId = "obs-1", observationType = heartRateType)) + ) + repository.mockSchedule.addSchedules( + listOf( + ScheduleEntity( + scheduleId = "sched-1", + observationId = "obs-1", + observationType = heartRateType, + start = Clock.System.now().epochSeconds, + state = ScheduleState.ACTIVE.name + ) + ) + ) + observation.collectAllData() + + observation.checkRequiredCollectorPermissions() + + assertEquals( + SharedRes.strings.observation_permission_missing_title.desc(), + AlertController.alertDialogModel.value?.title + ) + assertEquals(0, heartRateCollector.requestPermissionCallCount) + AlertController.closeAlertDialog() + } + + @Test + fun testCheckRequiredCollectorPermissionsReturnsCorrectStatePerDataType() = runTest { + val repository = MockMainRepository().withStudyStart() + val heartRateCollector = + FakeCollector(HealthConnectDataType.HEART_RATE, permission = PermissionApprovalState.GRANTED) + val stepsCollector = + FakeCollector(HealthConnectDataType.STEPS, permission = PermissionApprovalState.DECLINED) + val observation = HealthConnectObservation(repository, listOf(heartRateCollector, stepsCollector)) + observation.applyDataManager(mockObservationDataManager(repository)) + + repository.mockObservation.addObservations( + listOf( + ObservationEntity(observationId = "obs-1", observationType = heartRateType), + ObservationEntity(observationId = "obs-2", observationType = stepsType) + ) + ) + repository.mockSchedule.addSchedules( + listOf( + ScheduleEntity( + scheduleId = "sched-1", + observationId = "obs-1", + observationType = heartRateType, + start = Clock.System.now().epochSeconds, + state = ScheduleState.ACTIVE.name + ), + ScheduleEntity( + scheduleId = "sched-2", + observationId = "obs-2", + observationType = stepsType, + start = Clock.System.now().epochSeconds, + state = ScheduleState.ACTIVE.name + ) + ) + ) + observation.collectAllData() + + val states = observation.checkRequiredCollectorPermissions() + + assertEquals(PermissionApprovalState.GRANTED, states[HealthConnectDataType.HEART_RATE]) + assertEquals(PermissionApprovalState.DECLINED, states[HealthConnectDataType.STEPS]) + assertEquals(2, states.size) + AlertController.closeAlertDialog() + } + + @Test + fun testComputeWindowRespectsLastCollectionAndTaskStart() { + val now = Clock.System.now() + val lastCollection = now - 2.hours + val taskStart = now - 1.hours + + val window = Observation.computeWindow(lastCollection, taskStart, null, now) + + assertEquals(taskStart, window?.first) + assertEquals(now, window?.second) + } + + @Test + fun testComputeWindowRespectsTaskStop() { + val now = Clock.System.now() + val lastCollection = now - 2.hours + val taskStop = now - 1.hours + + val window = Observation.computeWindow(lastCollection, null, taskStop, now) + + assertEquals(lastCollection, window?.first) + assertEquals(taskStop, window?.second) + } + + @Test + fun testComputeWindowReturnsNullWhenLastCollectionIsAfterTaskStop() { + val now = Clock.System.now() + val taskStop = now - 2.hours + val lastCollection = now - 1.hours + + val window = Observation.computeWindow(lastCollection, null, taskStop, now) + + assertNull(window) + } + + @Test + fun testCollectAllDataRegistersRecentSchedulesBeforeCollecting() = runTest { + val repository = MockMainRepository().withStudyStart() + val heartRateCollector = FakeCollector(HealthConnectDataType.HEART_RATE) + val observation = HealthConnectObservation(repository, listOf(heartRateCollector)) + observation.applyDataManager(mockObservationDataManager(repository)) + + repository.mockObservation.addObservations( + listOf(ObservationEntity(observationId = "obs-1", observationType = heartRateType)) + ) + repository.mockSchedule.addSchedules( + listOf( + ScheduleEntity( + scheduleId = "sched-1", + observationId = "obs-1", + observationType = heartRateType, + start = Clock.System.now().epochSeconds, + state = ScheduleState.ACTIVE.name + ) + ) + ) + + observation.collectAllData() + + assertEquals(1, heartRateCollector.collectCallCount) + } + + @Test + fun testActivateAndDeactivateDrivePollingRegistry() { + val scheduler = object : PollingTaskScheduler { + var scheduledInterval: Long? = null + var cancelled = false + + override fun schedule(intervalMillis: Long) { + scheduledInterval = intervalMillis + } + + override fun cancel() { + cancelled = true + } + } + PollingObservationRegistry.init(scheduler, MockSharedStorageRepository()) + val observation = HealthConnectObservation(MockMainRepository(), emptyList()) + + observation.activate() + assertEquals(15 * 60 * 1000L, scheduler.scheduledInterval) + + observation.deactivate() + assertTrue(scheduler.cancelled) + } + + @Test + fun testCollectAllDataStoresLatestDataPointForMatchingScheduleOnly() = runTest { + val repository = MockMainRepository().withStudyStart() + val now = Clock.System.now() + val heartRateCollector = + FakeCollector(HealthConnectDataType.HEART_RATE, samples = listOf(HealthConnectSample.HeartRate(now, 72))) + val stepsCollector = FakeCollector(HealthConnectDataType.STEPS) + val observation = HealthConnectObservation(repository, listOf(heartRateCollector, stepsCollector)) + observation.applyDataManager(mockObservationDataManager(repository)) + + repository.mockObservation.addObservations( + listOf(ObservationEntity(observationId = "obs-1", observationType = heartRateType)) + ) + repository.mockSchedule.addSchedules( + listOf( + ScheduleEntity( + scheduleId = "sched-1", + observationId = "obs-1", + observationType = heartRateType, + start = Clock.System.now().epochSeconds, + state = ScheduleState.ACTIVE.name + ) + ) + ) + + observation.collectAllData() + + val latest = repository.mockObservation.latestDataPointForSchedule("sched-1").first() + assertEquals("obs-1", latest?.observationId) + assertEquals(heartRateType, latest?.observationType) + val data = latest?.dataValue?.jsonRead>() + val payload = data?.get("data") as? Map<*, *> + assertEquals(72L, payload?.get("hr")) + } + + @Test + fun testSampleTransformProducesExpectedPayload() { + val now = Clock.System.now() + val heartRate = HealthConnectSample.HeartRate(now, 72) + val steps = HealthConnectSample.Steps(now, 1000, now - 1.hours, now) + + assertEquals( + mapOf( + "timestamp" to now.toString(), + "data" to mapOf("hr" to 72) + ), + heartRate.transform() + ) + assertEquals( + mapOf( + "timestamp" to now.toString(), + "startTime" to (now - 1.hours).toString(), + "endTime" to now.toString(), + "data" to mapOf("steps" to 1000L) + ), + steps.transform() + ) + } + + @Test + fun testSampleTransformIncludesDeviceAndSourceApp() { + val now = Clock.System.now() + val heartRate = HealthConnectSample.HeartRate( + now, + 72, + device = "Apple Watch", + sourceApp = "com.apple.health" + ) + + val transformed = heartRate.transform() + + assertEquals("Apple Watch", transformed["device"]) + assertEquals(mapOf("sourceApp" to "com.apple.health"), transformed["additionalData"]) + } + + @Test + fun testStepsCollectionAggregatesSamplesIntoOneDailyDataPoint() = runTest { + val repository = MockMainRepository().withStudyStart() + val now = Clock.System.now() + val stepsCollector = FakeCollector( + HealthConnectDataType.STEPS, + samples = listOf( + HealthConnectSample.Steps(now - 30.minutes, 500, now - 1.hours, now - 30.minutes), + HealthConnectSample.Steps(now, 300, now - 30.minutes, now) + ) + ) + val observation = HealthConnectObservation(repository, listOf(stepsCollector)) + observation.applyDataManager(mockObservationDataManager(repository)) + + repository.mockObservation.addObservations( + listOf(ObservationEntity(observationId = "obs-1", observationType = stepsType)) + ) + repository.mockSchedule.addSchedules( + listOf( + ScheduleEntity( + scheduleId = "sched-1", + observationId = "obs-1", + observationType = stepsType, + start = Clock.System.now().epochSeconds, + state = ScheduleState.ACTIVE.name + ) + ) + ) + + observation.collectAllData() + + val latest = repository.mockObservation.latestDataPointForSchedule("sched-1").first() + val data = latest?.dataValue?.jsonRead>() + val payload = data?.get("data") as? Map<*, *> + assertEquals(800L, payload?.get("steps")) + } + + @Test + fun testStepsCollectionIncludesGoalAndDistanceOnTheAggregate() = runTest { + val repository = MockMainRepository().withStudyStart() + val now = Clock.System.now() + val stepsCollector = FakeCollector( + HealthConnectDataType.STEPS, + samples = listOf(HealthConnectSample.Steps(now, 500, now - 1.hours, now)), + distanceInMeters = 321.5 + ) + val observation = HealthConnectObservation(repository, listOf(stepsCollector)) + observation.applyDataManager(mockObservationDataManager(repository)) + + repository.mockObservation.addObservations( + listOf( + ObservationEntity( + observationId = "obs-1", + observationType = stepsType, + configuration = """{"targetSteps":10000}""" + ) + ) + ) + repository.mockSchedule.addSchedules( + listOf( + ScheduleEntity( + scheduleId = "sched-1", + observationId = "obs-1", + observationType = stepsType, + start = Clock.System.now().epochSeconds, + state = ScheduleState.ACTIVE.name + ) + ) + ) + + observation.collectAllData() + + val latest = repository.mockObservation.latestDataPointForSchedule("sched-1").first() + val data = latest?.dataValue?.jsonRead>() + val payload = data?.get("data") as? Map<*, *> + assertEquals(500L, payload?.get("steps")) + assertEquals(10000L, payload?.get("stepsGoal")) + assertEquals(321.5, payload?.get("distanceInMeters")) + } + + @Test + fun testRepeatedIdenticalStepsCollectionStaysConsistent() = runTest { + val repository = MockMainRepository().withStudyStart() + val now = Clock.System.now() + val stepsCollector = FakeCollector( + HealthConnectDataType.STEPS, + samples = listOf(HealthConnectSample.Steps(now, 500, now - 1.hours, now)) + ) + val observation = HealthConnectObservation(repository, listOf(stepsCollector)) + observation.applyDataManager(mockObservationDataManager(repository)) + + repository.mockObservation.addObservations( + listOf(ObservationEntity(observationId = "obs-1", observationType = stepsType)) + ) + repository.mockSchedule.addSchedules( + listOf( + ScheduleEntity( + scheduleId = "sched-1", + observationId = "obs-1", + observationType = stepsType, + start = Clock.System.now().epochSeconds, + state = ScheduleState.ACTIVE.name + ) + ) + ) + + observation.collectAllData() + observation.collectAllData() + + val latest = repository.mockObservation.latestDataPointForSchedule("sched-1").first() + val data = latest?.dataValue?.jsonRead>() + val payload = data?.get("data") as? Map<*, *> + assertEquals(500L, payload?.get("steps")) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservationTypeTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservationTypeTest.kt new file mode 100644 index 00000000..55a20a2c --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/healthConnect/HealthConnectObservationTypeTest.kt @@ -0,0 +1,21 @@ +package io.redlink.more.observations.healthConnect + +import io.redlink.more.HEALTH_CONNECT_PREFIX +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HealthConnectObservationTypeTest { + + private val heartRateType = "$HEALTH_CONNECT_PREFIX-heart-rate-observation" + private val stepsType = "$HEALTH_CONNECT_PREFIX-steps-observation" + + @Test + fun testMatchesBothSubTypesAndBaseType() { + val observationType = HealthConnectObservationType() + assertTrue(observationType.matches(heartRateType)) + assertTrue(observationType.matches(stepsType)) + assertTrue(observationType.matches("$HEALTH_CONNECT_PREFIX-observation")) + assertFalse(observationType.matches("other-observation")) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/polling/PollingObservationRegistryTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/polling/PollingObservationRegistryTest.kt new file mode 100644 index 00000000..27a66fec --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/polling/PollingObservationRegistryTest.kt @@ -0,0 +1,78 @@ +package io.redlink.more.observations.polling + +import io.redlink.more.mocks.MockSharedStorageRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PollingObservationRegistryTest { + + private class FakeScheduler : PollingTaskScheduler { + var scheduleCalls = mutableListOf() + var cancelCalls = 0 + + override fun schedule(intervalMillis: Long) { + scheduleCalls.add(intervalMillis) + } + + override fun cancel() { + cancelCalls++ + } + } + + @Test + fun testActivateSchedulesOnce() { + val scheduler = FakeScheduler() + PollingObservationRegistry.init(scheduler, MockSharedStorageRepository()) + + PollingObservationRegistry.activate("health-connect-observation", 1000L) + + assertEquals(listOf(1000L), scheduler.scheduleCalls) + assertTrue("health-connect-observation" in PollingObservationRegistry.activeObservationTypes()) + } + + @Test + fun testActivateWithSameIntervalDoesNotReschedule() { + val scheduler = FakeScheduler() + PollingObservationRegistry.init(scheduler, MockSharedStorageRepository()) + + PollingObservationRegistry.activate("health-connect-observation", 1000L) + PollingObservationRegistry.activate("health-connect-observation", 1000L) + + assertEquals(1, scheduler.scheduleCalls.size) + } + + @Test + fun testDeactivateCancelsOnceEmpty() { + val scheduler = FakeScheduler() + PollingObservationRegistry.init(scheduler, MockSharedStorageRepository()) + + PollingObservationRegistry.activate("health-connect-observation", 1000L) + PollingObservationRegistry.deactivate("health-connect-observation") + + assertEquals(1, scheduler.cancelCalls) + assertTrue(PollingObservationRegistry.activeObservationTypes().isEmpty()) + } + + @Test + fun testDeactivateWithoutActivationIsNoop() { + val scheduler = FakeScheduler() + PollingObservationRegistry.init(scheduler, MockSharedStorageRepository()) + + PollingObservationRegistry.deactivate("health-connect-observation") + + assertEquals(0, scheduler.cancelCalls) + } + + @Test + fun testActivationsPersistAndReloadOnInit() { + val storage = MockSharedStorageRepository() + PollingObservationRegistry.init(FakeScheduler(), storage) + PollingObservationRegistry.activate("health-connect-observation", 1000L) + + val reloadedScheduler = FakeScheduler() + PollingObservationRegistry.init(reloadedScheduler, storage) + + assertEquals(setOf("health-connect-observation"), PollingObservationRegistry.activeObservationTypes()) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/registration/RegistrationServiceTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/registration/RegistrationServiceTest.kt new file mode 100644 index 00000000..29abc8da --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/registration/RegistrationServiceTest.kt @@ -0,0 +1,131 @@ +package io.redlink.more.registration + +import io.redlink.more.Shared +import io.redlink.more.mocks.InMemoryStorageRepository +import io.redlink.more.mocks.MockBluetoothConnector +import io.redlink.more.mocks.MockDataRecorder +import io.redlink.more.mocks.MockLocalNotificationListener +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockObservationFactory +import io.redlink.more.mocks.mockObservationDataManager +import io.redlink.more.models.LoginModel +import io.redlink.more.scopes.AppDispatchers +import io.redlink.more.services.network.MockNetworkWatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class RegistrationServiceTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var registrationService: RegistrationService + + private fun createMockShared(): Shared { + val mockRepo = MockMainRepository() + return object : Shared( + localNotificationListener = MockLocalNotificationListener(), + repositories = mockRepo, + sharedStorageRepository = InMemoryStorageRepository(), + observationDataManager = mockObservationDataManager(mockRepo), + mainBluetoothConnector = MockBluetoothConnector(), + observationFactory = MockObservationFactory(mockRepo), + dataRecorder = MockDataRecorder(), + networkWatcher = MockNetworkWatcher(), + connectionStatusFlow = MutableStateFlow(true), + isDebug = true + ) {} + } + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + AppDispatchers.set(testDispatcher, testDispatcher, testDispatcher) + registrationService = RegistrationService(createMockShared()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + AppDispatchers.reset() + } + + @Test + fun `given new consent flow when beginConsentSubmission then isLoading is true`() = runTest { + assertFalse(registrationService.isLoading.value) + + registrationService.beginConsentSubmission() + + assertTrue(registrationService.isLoading.value) + } + + @Test + fun `given in progress consent submission when cancelConsentSubmission then isLoading is false`() = + runTest { + registrationService.beginConsentSubmission() + assertTrue(registrationService.isLoading.value) + + registrationService.cancelConsentSubmission() + + assertFalse(registrationService.isLoading.value) + } + + @Test + fun `given missing login and study when acceptConsent then isLoading is reset to false immediately`() = + runTest { + registrationService.beginConsentSubmission() + assertTrue(registrationService.isLoading.value) + + registrationService.acceptConsent("test-device-id") + + assertFalse(registrationService.isLoading.value) + } + + @Test + fun `given valid login but missing study when acceptConsent then isLoading is reset to false immediately`() = + runTest { + val validLoginFlow = + registrationService.validLoginModel as MutableStateFlow + validLoginFlow.value = LoginModel("token", "https://example.com") + + registrationService.beginConsentSubmission() + assertTrue(registrationService.isLoading.value) + + registrationService.acceptConsent("test-device-id") + + assertFalse(registrationService.isLoading.value) + } + + @Test + fun `given valid login and study when acceptConsent then isLoading transitions to false once network completes`() = + runTest { + registrationService.sendRegistrationToken( + LoginModel( + "DEMO", + "https://demo.more-platform.org" + ) + ) + runCurrent() + + assertNotNull(registrationService.validLoginModel.value) + assertNotNull(registrationService.study.value) + + registrationService.beginConsentSubmission() + assertTrue(registrationService.isLoading.value) + + registrationService.acceptConsent("test-device-id") + runCurrent() + + assertFalse(registrationService.isLoading.value) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/services/network/DemoNetworkServiceTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/services/network/DemoNetworkServiceTest.kt new file mode 100644 index 00000000..2a6e1d25 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/services/network/DemoNetworkServiceTest.kt @@ -0,0 +1,97 @@ +package io.redlink.more.services.network + +import io.redlink.more.models.LoginModel +import io.redlink.more.observations.healthConnect.HealthConnectDataType +import io.redlink.more.services.network.demo.DemoNetworkService +import io.redlink.more.services.network.openapi.model.StudyConsent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class DemoNetworkServiceTest { + + private val demoNetworkService = DemoNetworkService() + + @Test + fun testValidateRegistrationToken_Demo() = runTest { + val loginModel = LoginModel(token = "DEMO", endpoint = "https://demo.more-platform.org") + val (study, error) = demoNetworkService.validateRegistrationToken(loginModel) + + assertNotNull(study) + assertNull(error) + assertEquals("Demo Study", study.studyTitle) + } + + @Test + fun testValidateRegistrationToken_Invalid() = runTest { + val loginModel = LoginModel(token = "INVALID", endpoint = "https://demo.more-platform.org") + val (study, error) = demoNetworkService.validateRegistrationToken(loginModel) + + assertNull(study) + assertNotNull(error) + assertEquals(404, error.code) + } + + @Test + fun testGetStudyConfig() = runTest { + val (study, error) = demoNetworkService.getStudyConfig(null) + + assertNotNull(study) + assertNull(error) + assertEquals("Demo Study", study.studyTitle) + } + + @Test + fun testSendConsent() = runTest { + val loginModel = LoginModel(token = "DEMO", endpoint = "https://demo.more-platform.org") + val (config, error) = demoNetworkService.sendConsent( + loginModel, + StudyConsent( + consent = true, + observations = emptyList(), + consentInfoMD5 = "", + deviceId = "" + ) + ) + + assertNotNull(config) + assertNull(error) + assertEquals("DEMO_ID", config.credentials.apiId) + assertEquals("DEMO_KEY", config.credentials.apiKey) + } + + @Test + fun testHealthConnectObservationsInStudy() = runTest { + val (study, _) = demoNetworkService.getStudyConfig(null) + assertNotNull(study) + val observations = study.observations + + val heartRateObservation = observations.find { it.observationId == "9" } + assertNotNull(heartRateObservation) + assertEquals(HealthConnectDataType.HEART_RATE.subTypeValue, heartRateObservation.observationType) + + val stepsObservation = observations.find { it.observationId == "10" } + assertNotNull(stepsObservation) + assertEquals(HealthConnectDataType.STEPS.subTypeValue, stepsObservation.observationType) + } + + @Test + fun testDownloadMissedNotifications() = runTest { + val notifications = demoNetworkService.downloadMissedNotifications() + assertEquals(5, notifications.size) + + assertEquals("demo_1", notifications[0].msgId) + assertEquals("Welcome to the Study!", notifications[0].title) + assertNull(notifications[0].deepLink) + + assertEquals("demo_2", notifications[1].msgId) + assertEquals("Daily Mood Check", notifications[1].title) + assertEquals("more://task-details?observationId=1", notifications[1].deepLink) + + assertEquals("demo_3", notifications[2].msgId) + assertEquals("New Observation available", notifications[2].title) + assertEquals("more://task-details?observationId=2", notifications[2].deepLink) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/services/network/MockNetworkWatcher.kt b/shared/src/commonTest/kotlin/io/redlink/more/services/network/MockNetworkWatcher.kt new file mode 100644 index 00000000..5d9bbccd --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/services/network/MockNetworkWatcher.kt @@ -0,0 +1,24 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung 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/). + */ +package io.redlink.more.services.network + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class MockNetworkWatcher(initialState: Boolean = true) : NetworkWatcher { + private val _state = MutableStateFlow(initialState) + + override fun watchNetworkState(): Flow = _state + + fun setState(state: Boolean) { + _state.value = state + } +} diff --git a/shared/src/iosMain/kotlin/io/redlink/more/events/IosDayMonitor.kt b/shared/src/iosMain/kotlin/io/redlink/more/events/IosDayMonitor.kt new file mode 100644 index 00000000..7c07ab37 --- /dev/null +++ b/shared/src/iosMain/kotlin/io/redlink/more/events/IosDayMonitor.kt @@ -0,0 +1,98 @@ +package io.redlink.more.events + +import io.redlink.more.extensions.today +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import platform.Foundation.NSCalendarDayChangedNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSSystemClockDidChangeNotification +import platform.Foundation.NSSystemTimeZoneDidChangeNotification +import kotlin.time.Clock + +actual class DayMonitor actual constructor( + private val onEvent: (AppEvent) -> Unit +) { + private val notificationCenter = + NSNotificationCenter.defaultCenter + + private var calendarObserver: Any? = null + private var clockObserver: Any? = null + private var timezoneObserver: Any? = null + + private var lastKnownDate = LocalDate.today() + private var lastKnownTimeZone = TimeZone.currentSystemDefault() + + actual fun start() { + if (calendarObserver != null) { + return + } + + calendarObserver = + notificationCenter.addObserverForName( + name = NSCalendarDayChangedNotification, + `object` = null, + queue = null + ) { + onEvent(AppEvent.DayChanged(LocalDate.today())) + } + + clockObserver = + notificationCenter.addObserverForName( + name = NSSystemClockDidChangeNotification, + `object` = null, + queue = null + ) { + onEvent(AppEvent.SystemTimeChanged(Clock.System.now())) + } + + timezoneObserver = + notificationCenter.addObserverForName( + name = NSSystemTimeZoneDidChangeNotification, + `object` = null, + queue = null + ) { + onEvent(AppEvent.TimeZoneChanged(TimeZone.currentSystemDefault())) + } + } + + actual fun refresh() { + updateTimeZone() + updateDate() + } + + private fun updateDate() { + val currentDate = LocalDate.today() + + if (currentDate != lastKnownDate) { + lastKnownDate = currentDate + onEvent(AppEvent.DayChanged(currentDate)) + } + } + + private fun updateTimeZone() { + val currentTimeZone = TimeZone.currentSystemDefault() + + if (currentTimeZone != lastKnownTimeZone) { + lastKnownTimeZone = currentTimeZone + onEvent(AppEvent.TimeZoneChanged(currentTimeZone)) + } + } + + actual fun stop() { + calendarObserver?.let { + notificationCenter.removeObserver(it) + } + + clockObserver?.let { + notificationCenter.removeObserver(it) + } + + timezoneObserver?.let { + notificationCenter.removeObserver(it) + } + + calendarObserver = null + clockObserver = null + timezoneObserver = null + } +} diff --git a/shared/src/iosMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectStrings.kt b/shared/src/iosMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectStrings.kt new file mode 100644 index 00000000..1a3f19fb --- /dev/null +++ b/shared/src/iosMain/kotlin/io/redlink/more/observations/healthConnect/HealthConnectStrings.kt @@ -0,0 +1,12 @@ +package io.redlink.more.observations.healthConnect + +import dev.icerock.moko.resources.StringResource +import io.redlink.more.SharedRes + +actual object HealthConnectStrings { + actual val providerTypeString: StringResource = SharedRes.strings.type_health_connect_ios + actual val providerShortTypeString: StringResource = + SharedRes.strings.type_health_connect_ios_short + actual val heartRateTypeString: StringResource = SharedRes.strings.type_health_connect_heart_rate + actual val stepsTypeString: StringResource = SharedRes.strings.type_health_connect_steps +} From 992631bdb1c99a7c2f7b572c7b6c52a46d72ec88 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Tue, 18 Aug 2026 11:07:58 +0200 Subject: [PATCH 5/6] #414: Fixed all merge conflcits and tested everything --- CLAUDE.md | 149 ++++++++++++++++++ iosApp/fastlane/Fastfile | 2 +- iosApp/iosApp.xcodeproj/project.pbxproj | 35 ++-- .../xcshareddata/xcschemes/iosApp.xcscheme | 2 +- iosApp/iosApp/AppDelegate.swift | 5 +- iosApp/iosApp/InfoPlist.xcstrings | 14 +- .../AccelerometerRecorderCollector.swift | 10 +- iosApp/iosApp/Style/MoreColor.swift | 49 ++++++ .../Views/Components/CheckboxField.swift | 2 +- iosApp/iosApp/Views/Consent/ConsentView.swift | 5 +- .../Views/Consent/ConsentViewModel.swift | 2 +- iosApp/iosApp/Views/Login/LoginButton.swift | 2 +- iosApp/iosApp/Views/Login/LoginView.swift | 5 +- .../iosApp/Views/Login/ScanQRCodeView.swift | 4 +- .../Views/ObservationErrorListView.swift | 2 +- 15 files changed, 240 insertions(+), 48 deletions(-) create mode 100644 CLAUDE.md create mode 100644 iosApp/iosApp/Style/MoreColor.swift diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..b753f1b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,149 @@ +# CLAUDE.md + +## Project overview + +This is the participant-facing mobile app for the MORE health study platform, built with **Kotlin +Multiplatform Mobile (KMP)**: shared business logic in `shared/`, a native Android UI in `androidApp/` +(Jetpack Compose), and a native iOS UI in `iosApp/` (SwiftUI, Xcode project). The app enrolls +participants into studies, runs scheduled "observations" (sensor/survey data collection), stores data +locally, and syncs with a backend (the `more-studymanager-backend` / `more-data-gateway` services). + +Implement everything possible in the shared core, only system depending on the native API or UI are implemented platform specific. +Always check available tools and skills. Do not implement APIs that do not exist. +Always clean and build gradle to regenerate the API. +Always test the code afterward and write at least unit tests. +Always build the project for android using gradle and ios using xcodebuild, to see that everything works. + +Never commit, never push anything, Manual review is always necessary. + +## Build & common commands + +- Generate the OpenAPI client (**required before first build, and any time `openapi/*.yaml` changes**): + `./gradlew :shared:generateOpenApiClasses` + (This task is also wired as a `dependsOn` for compile/KSP/test tasks, so a plain build will trigger it, + but running it explicitly avoids stale-generated-code confusion.) +- Full build: `./gradlew build` +- Run shared-module unit tests (all KMP tests live here — there is no separate Android/iOS test suite): + `./gradlew :shared:testDebugUnitTest` (Android target) or `./gradlew :shared:allTests` +- Run a single test class: `./gradlew :shared:testDebugUnitTest --tests "io.redlink.more.observations.ObservationManagerTest"` +- Android app assemble only: `./gradlew :androidApp:assembleDebug` +- iOS: open `iosApp/iosApp.xcodeproj` in Xcode and build/run the `iosApp` scheme (or `BlendedCare` + scheme for the white-label variant). The iOS app consumes `shared` as a compiled framework — Gradle + does not build it; Xcode's build phases invoke the KMP framework build via `embedAndSignAppleFrameworkForXcode`. +- Google services files (`google-services.json` / `GoogleService-Info.plist`) are required to build + either app. `./setup_google_services.sh` creates them from a base64 `GOOGLE_API_KEY` env var; without + it you must place the files manually in `androidApp/` and `iosApp/iosApp/` respectively. + +## High-level architecture + +### Module layout +- `shared/` — KMP module (`commonMain`/`androidMain`/`iosMain`/`commonTest`), package root + `io.redlink.more`. Contains essentially all business logic: networking, persistence, the + observation/scheduling engine, view models, and moko-resources based localized strings + (`SharedRes`, edited via `shared/src/commonMain/moko-resources/{base,de}/strings.xml`). +- `androidApp/` — thin Android shell (`io.redlink.more.app.android`): Activities, Compose screens, + platform services (WorkManager workers, Health Connect, Firebase, Polar BLE), all delegating to + `shared`. +- `iosApp/` — thin SwiftUI shell consuming the compiled `shared` framework, mirroring the Android + package structure 1:1 (e.g. `iosApp/iosApp/Observations/` ↔ `androidApp/.../observations/`, + `Views/PC/` ↔ Android's `pc/composables/`). When adding a feature, expect to touch both native shells + plus shared code, since almost nothing is platform-shared UI. +- Cross-cutting rule: **when you change a Room entity/schema, bump `version` in + `shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt` and add a `Migration` in + `shared/src/commonMain/kotlin/io/redlink/more/database/migrations/`.** Skipping this crashes the app + on upgrade for already-installed users. (The README's mention of a "RealmDatabase.kt" is stale — the + project now uses **Room**, not Realm.) + +### Composition root — no DI framework +There's no Koin/Dagger/Hilt graph despite a `koin-bom` dependency in `androidApp/build.gradle.kts`. +Instead, `shared/src/commonMain/kotlin/io/redlink/more/Shared.kt` is a single facade class that +constructs and wires every singleton service (`NetworkService`, `MainRepository`, `ObservationFactory`, +`NotificationManager`, `BluetoothController`, `ObservationDataManager`, …) via constructor injection. +Each platform builds one `Shared` instance at startup by passing in its platform-specific +implementations: +- Android: `MoreApplication.initShared()` (`androidApp/.../MoreApplication.kt`) builds the Room database, + `AndroidObservationFactory`, `AndroidPollingTaskScheduler`, `AndroidDataRecorder`, etc., and stores the + singleton `Shared` instance on the `MoreApplication` companion object. +- iOS: the equivalent wiring happens in `iosApp/iosApp/AppDelegate.swift`, constructing + `IOSObservationFactory`, `IOSObservationPermissionObserver`, `IOSPollingTaskScheduler`, etc. + +`expect`/`actual` is reserved for small platform primitives (`Platform.kt`, `DatabaseManager.kt` for the +Room builder, `UUID.kt`, `HttpClientReceiver.kt` for the Ktor engine) — the bigger platform seams +(observation factories, permission observers, polling schedulers, BLE/HealthKit/HealthConnect +collectors) are plain abstract classes in `commonMain` with a concrete subclass per platform +(`AndroidXxx` in Kotlin, `IOSXxx` in Swift interop'ing with the shared framework). + +### The observation engine (core domain concept) +"Observations" are the pluggable data-collection units (accelerometer, Health Connect/HealthKit +vitals, app usage, Garmin, Polar heart rate BLE, Limesurvey, in-app questions, ...). Key types in +`shared/src/commonMain/kotlin/io/redlink/more/observations/`: +- `Observation` (`Observation.kt`) — abstract base every observation type extends. Owns the + start/stop/permission/data-storage lifecycle shared by all types: `start()`/`stop()`, + permission request/approval via `ObservationPermissionObserver`, writing collected data through + `storeData`/`storeInstant`/long-running-observation helpers into the `ObservationDataManager`, and + activating/deactivating background polling (`PollingObservationRegistry`) for types that need it. +- `ObservationFactory` (`ObservationFactory.kt`) — abstract registry that (a) registers built-in + observation providers (`registerObservation { SomeObservation(repo) }`), (b) lazily instantiates only + the observation types actually needed by the current study's schedule config + (`initializeNeededObservations`, following `dependentObservationTypes` transitively), and (c) fans out + cross-cutting operations (permission checks, error checks, BLE device discovery) across all active + observations. Platform subclasses (`AndroidObservationFactory`, `IOSObservationFactory`) register the + platform-specific observation types (Health Connect, HealthKit, Polar, accelerometer) on top of the + types registered in the shared base class. +- `observationTypes/ObservationType` and its per-type subclasses (`AccelerometerType`, `GPSType`, + `GarminType`, `QuestionType`, `LimeSurveyType`, `AppUsageObservationType`, health-connect types, …) — + identify/match an observation by its backend-configured type string and declare required sensor + permissions and dependent types. +- `Collector`/`PermissionCollector`/`BundledPermissionCollector` (in `ObservationFactory.kt`) and + `ManualObserver`/`ManualDataCollection` (`observers/`) — smaller interfaces an `Observation` + implementation composes to request permissions or expose a "collect once, on demand" hook used by + background polling (`ObservationFactory.pollActiveObservations()`). +- `polling/PollingTaskScheduler` + `PollingObservationRegistry` — cross-platform abstraction over + background execution (WorkManager `PollingWorker` on Android, `BGAppRefreshTask` via + `IOSPollingTaskScheduler`/`PollingBackgroundTask` on iOS) used by observations that declare a + `pollIntervalMillis()`. + +To add a new observation type: create an `ObservationType` subclass, an `Observation` subclass in +`shared/commonMain`, register it via `registerObservation` (in the shared `ObservationFactory` if +cross-platform, or in `AndroidObservationFactory`/`IOSObservationFactory` if platform-specific), and add +any needed platform collector implementations under `androidApp/.../observations/` and +`iosApp/iosApp/Observations/`. + +### Networking +Ktor-based (`shared/src/commonMain/kotlin/io/redlink/more/services/network/`). `NetworkClients.kt` +lazily builds/caches per-endpoint API clients (`ConfigurationApi`, `DataApi`, `RegistrationApi`, +`NotificationsApi`, `GarminRegistrationApi`) using HTTP Basic Auth from `CredentialRepository`, and +invalidates all cached clients when credentials or the endpoint change. The API classes themselves +(`io.redlink.more.services.network.openapi.*`) are **generated code** from +`openapi/MobileAppAPI.yaml` via the `generateOpenApiClasses` Gradle task — do not hand-edit anything +under `shared/build/generated/open_api/`; change the YAML spec instead and regenerate. +`NetworkServiceProxy` wraps the real `NetworkServiceImpl` with an optional `DemoNetworkService` +(`services/network/demo/`) used for demo-mode/offline walkthroughs. + +### Persistence +Room (KMP, via `androidx.room` + KSP) in `shared/src/commonMain/kotlin/io/redlink/more/database/`: +`AppDatabase.kt` declares entities/DAOs; `repository/` wraps DAOs in repository interfaces + impls +(`MainRepository` aggregates all of them and is what most of the rest of the app depends on); +`migrations/` holds `Migration_x_y` classes. Schema JSON snapshots are exported to `shared/schemas/` +(configured via `room { schemaDirectory(...) }`). + +### Tests +All automated tests live in `shared/src/commonTest/` (common KMP tests, run on the JVM) — there is no +separate Android instrumented test suite or iOS test target in active use. Tests use hand-written +fakes/mocks under `shared/src/commonTest/kotlin/io/redlink/more/mocks/` (no mocking framework) rather +than a DI container, since the composition root (`Shared`) is wired manually. + +## CI/CD +GitHub Actions (`.github/workflows/`) builds both apps via **fastlane** on every push/PR +(`build.yml`) and deploys to TestFlight/Play Store Beta when a `x.x.x` semver tag is pushed +(`deploy-beta.yml`). Android version code for release builds is derived from the semver tag +(`major*10⁴ + minor*10² + patch`), taking the max against the latest Play Store version code. See the +README's "CI/CD Pipeline" section for the full list of required secrets/vars if you need to touch the +fastlane lanes (`androidApp/fastlane/`, `iosApp/fastlane/`). + +## Notes +- `graphify-out/` contains generated codebase-graph reports (not hand-maintained; ignore unless asked + to work with them). +- This repository is frequently worked on via long-lived feature/merge branches (see current branch + `healthConnectMerge2008`) — check `git status` before assuming the working tree is in a clean, + fully-compiling state; mid-merge branches can have partially-integrated code. diff --git a/iosApp/fastlane/Fastfile b/iosApp/fastlane/Fastfile index ca2ddf6e..63814cef 100644 --- a/iosApp/fastlane/Fastfile +++ b/iosApp/fastlane/Fastfile @@ -47,7 +47,7 @@ platform :ios do create_keychain( name: keychain_name, password: ENV["FASTLANE_KEYCHAIN_PASSWORD"], - default_keychain: false, + default_keychain: !ENV["CI"].to_s.empty?, unlock: true, timeout: 3600, lock_when_sleeps: false diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 4968c4bf..cc776bc9 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -72,7 +72,6 @@ 1F6A4E3E29F6D0D200F0247F /* BluetoothDeviceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6A4E3D29F6D0D200F0247F /* BluetoothDeviceExtension.swift */; }; 1F6C31512A121EA500EED533 /* WebViewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6C31502A121EA500EED533 /* WebViewViewModel.swift */; }; 1F6C31532A13EB7F00EED533 /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1F6C31522A13EB7F00EED533 /* Launch Screen.storyboard */; }; - 1F6F8F3F302B580C0012A1A9 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1F6F8F3E302B580C0012A1A9 /* GoogleService-Info.plist */; }; 1F6F8F42302C3ACC0012A1A9 /* StepsHealthConnectCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6F8F41302C3ACC0012A1A9 /* StepsHealthConnectCollector.swift */; }; 1F6F8F43302C3ACC0012A1A9 /* HeartRateHealthConnectCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6F8F40302C3ACC0012A1A9 /* HeartRateHealthConnectCollector.swift */; }; 1F750CAD2A6FA771006E455E /* StudyPausedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F750CAC2A6FA771006E455E /* StudyPausedView.swift */; }; @@ -112,7 +111,6 @@ 1F988B3F2F2BA0CA0094F99F /* DailyBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F988B3E2F2BA0CA0094F99F /* DailyBackgroundTask.swift */; }; 1F9C3E8E298AAC1A00B9AC82 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9C3E8D298AAC1A00B9AC82 /* LoginView.swift */; }; 1F9C3E90298AACC100B9AC82 /* MoreMainBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9C3E8F298AACC100B9AC82 /* MoreMainBackgroundView.swift */; }; - 1F9DB1A0298CF44000DBB7DB /* MoreColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9DB19F298CF44000DBB7DB /* MoreColor.swift */; }; 1F9DB1A3298D022E00DBB7DB /* MoreImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1F9DB1A2298D022E00DBB7DB /* MoreImages.xcassets */; }; 1F9DB1A5298D02FB00DBB7DB /* MoreColors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1F9DB1A4298D02FB00DBB7DB /* MoreColors.xcassets */; }; 1FA044462F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA044452F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift */; }; @@ -126,6 +124,7 @@ 1FC4F87429D2B86100F65026 /* DataUploadBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC4F87329D2B86100F65026 /* DataUploadBackgroundTask.swift */; }; 1FC9574E2C072B7900EB92D6 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC9574D2C072B7900EB92D6 /* NotificationService.swift */; }; 1FC957522C072B7900EB92D6 /* More-Notification-Service-Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1FC9574B2C072B7900EB92D6 /* More-Notification-Service-Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 1FCD64143038512500B38B88 /* MoreColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FCD64133038512500B38B88 /* MoreColor.swift */; }; 1FDC264829C1CEF40011D8A4 /* InfoListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDC264729C1CEF40011D8A4 /* InfoListItem.swift */; }; 1FDC264A29C1CFE80011D8A4 /* NavigationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDC264929C1CFE80011D8A4 /* NavigationText.swift */; }; 1FDC264C29C1D1660011D8A4 /* InfoList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDC264B29C1D1660011D8A4 /* InfoList.swift */; }; @@ -286,7 +285,6 @@ 1F45E5BA2F288D7500EE8487 /* ExitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExitButton.swift; sourceTree = ""; }; 1F45E5BC2F288DD600EE8487 /* ReloadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReloadButton.swift; sourceTree = ""; }; 1F5A248C29C893B3008140CF /* AccelerometerRecorderCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccelerometerRecorderCollector.swift; sourceTree = ""; }; - 1F5BEBE12FE9219100224B4C /* AlertBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertBannerView.swift; sourceTree = ""; }; 1F5F842529E6C67A0010C2D2 /* LocalPushNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPushNotificationService.swift; sourceTree = ""; }; 1F60C58529951A5F00858581 /* ErrorText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorText.swift; sourceTree = ""; }; 1F638B7329D6B46300455B66 /* CMLogItemExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMLogItemExtension.swift; sourceTree = ""; }; @@ -296,7 +294,6 @@ 1F6A4E3D29F6D0D200F0247F /* BluetoothDeviceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothDeviceExtension.swift; sourceTree = ""; }; 1F6C31502A121EA500EED533 /* WebViewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewViewModel.swift; sourceTree = ""; }; 1F6C31522A13EB7F00EED533 /* Launch Screen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = ""; }; - 1F6F8F3E302B580C0012A1A9 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "GoogleService-Info.plist"; sourceTree = ""; }; 1F6F8F40302C3ACC0012A1A9 /* HeartRateHealthConnectCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeartRateHealthConnectCollector.swift; sourceTree = ""; }; 1F6F8F41302C3ACC0012A1A9 /* StepsHealthConnectCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepsHealthConnectCollector.swift; sourceTree = ""; }; 1F750CAC2A6FA771006E455E /* StudyPausedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyPausedView.swift; sourceTree = ""; }; @@ -337,7 +334,6 @@ 1F988B3E2F2BA0CA0094F99F /* DailyBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyBackgroundTask.swift; sourceTree = ""; }; 1F9C3E8D298AAC1A00B9AC82 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 1F9C3E8F298AACC100B9AC82 /* MoreMainBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMainBackgroundView.swift; sourceTree = ""; }; - 1F9DB19F298CF44000DBB7DB /* MoreColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreColor.swift; sourceTree = ""; }; 1F9DB1A2298D022E00DBB7DB /* MoreImages.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MoreImages.xcassets; sourceTree = ""; }; 1F9DB1A4298D02FB00DBB7DB /* MoreColors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MoreColors.xcassets; sourceTree = ""; }; 1FA044452F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSObservationPermissionObserver.swift; sourceTree = ""; }; @@ -351,6 +347,7 @@ 1FC9574D2C072B7900EB92D6 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 1FC9574F2C072B7900EB92D6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 1FC957572C072C1F00EB92D6 /* BlendedCare-Notification-Service-Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "BlendedCare-Notification-Service-Extension.entitlements"; sourceTree = ""; }; + 1FCD64133038512500B38B88 /* MoreColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreColor.swift; sourceTree = ""; }; 1FDC264729C1CEF40011D8A4 /* InfoListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoListItem.swift; sourceTree = ""; }; 1FDC264929C1CFE80011D8A4 /* NavigationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationText.swift; sourceTree = ""; }; 1FDC264B29C1D1660011D8A4 /* InfoList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoList.swift; sourceTree = ""; }; @@ -573,13 +570,6 @@ path = GarminConnect; sourceTree = ""; }; - 1F60BFC92FA8A423007CD41A /* PC_Components */ = { - isa = PBXGroup; - children = ( - ); - path = PC_Components; - sourceTree = ""; - }; 1F6A4E3A29F6C94B00F0247F /* Bluetooth */ = { isa = PBXGroup; children = ( @@ -742,7 +732,6 @@ 1F9C3E8B298AAC0000B9AC82 /* Views */ = { isa = PBXGroup; children = ( - 1F60BFC92FA8A423007CD41A /* PC_Components */, 1F43DE1F2EC6136700B6F07B /* GarminConnect */, 1F1E45CF2E7842E300C82016 /* Registration */, 1F8937892BFF1EB20083D20E /* ObservationErrors */, @@ -789,7 +778,6 @@ 1F9DB1A1298CF57600DBB7DB /* Style */ = { isa = PBXGroup; children = ( - 1F9DB19F298CF44000DBB7DB /* MoreColor.swift */, 1F8847C72991535B0023EF10 /* MoreFontWeight.swift */, 1F8847C9299154120023EF10 /* MoreFont.swift */, 1F8847D3299158BC0023EF10 /* MoreImage.swift */, @@ -800,6 +788,7 @@ 1F8847EB2992C3240023EF10 /* MoreFrame.swift */, 1F13BA4A299398FD00938C1E /* MoreTextStyle.swift */, 1F13BA4C29939E4F00938C1E /* MoreListStyleEdgeInsets.swift */, + 1FCD64133038512500B38B88 /* MoreColor.swift */, ); path = Style; sourceTree = ""; @@ -1078,7 +1067,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1540; - LastUpgradeCheck = 1530; + LastUpgradeCheck = 2660; ORGANIZATIONNAME = "Redlink GmbH"; TargetAttributes = { 1FC9574A2C072B7900EB92D6 = { @@ -1230,7 +1219,7 @@ 1F750CAD2A6FA771006E455E /* StudyPausedView.swift in Sources */, EDEF4C8329EFD4CA00E830DA /* RunningSchedules.swift in Sources */, 1F6F8F42302C3ACC0012A1A9 /* StepsHealthConnectCollector.swift in Sources */, - 1F6F8F43302C3ACC0012A1A9 /* HeartRateHealthConnectCollector.swift in Sources */, + 1F6F8F43302C3ACC0012A1A9 /* HeartRateHealthConnectCollector.swift in Sources */, 1F8847D829915C030023EF10 /* MoreTextField.swift in Sources */, 1F0026DF29CCA24F0034EF65 /* DataUploadManager.swift in Sources */, 1F43997C29B8D70800687906 /* ObservationDetails.swift in Sources */, @@ -1329,11 +1318,13 @@ B748DF4829D1C07F0026C348 /* QuestionViewModel.swift in Sources */, 1F8847EA2992C0060023EF10 /* MoreContainer.swift in Sources */, 1F89378B2BFF1EBF0083D20E /* ObservationErrorsView.swift in Sources */, + 1F89378D2BFF1F8D0083D20E /* ObservationErrorsViewModel.swift in Sources */, B74DDB6A29E6AEDE006FEA74 /* NotificationItem.swift in Sources */, 1F27536B29CC68FC00324417 /* ObservationExtension.swift in Sources */, 1F8847EC2992C3240023EF10 /* MoreFrame.swift in Sources */, 1F8847E82992BEE30023EF10 /* BasicText.swift in Sources */, 1F750CAF2A6FA8AE006E455E /* StudyClosedView.swift in Sources */, + 1FCD64143038512500B38B88 /* MoreColor.swift in Sources */, B746706D2DF03FE800676D79 /* ScanQRCodeView.swift in Sources */, 1F43DE232EC6137F00B6F07B /* GarminConnectViewModel.swift in Sources */, B78B4A7E29F0420700A1BA58 /* ObservationDetailsViewModel.swift in Sources */, @@ -1348,7 +1339,6 @@ 1F43998C29C1006800687906 /* NotificationView.swift in Sources */, EDB16E2B29F933EF00701C27 /* TaskCompletionBarViewModel.swift in Sources */, 07FD293C29D57F0300853108 /* ModuleListItem.swift in Sources */, - 1F9DB1A0298CF44000DBB7DB /* MoreColor.swift in Sources */, B70888492DF2D4290048A4AC /* QRCodeScanDelegate.swift in Sources */, B79DBA4729D3141400A1F547 /* NavigationLinkButton.swift in Sources */, 07BC54B229CB47C400459267 /* DetailsTitle.swift in Sources */, @@ -1384,7 +1374,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1.0.0; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = VX2DSGURUH; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1424,7 +1413,6 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1.0.0; - DEVELOPMENT_TEAM = VX2DSGURUH; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1491,6 +1479,7 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = VX2DSGURUH; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -1514,7 +1503,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; - STRIP_STYLE = debugging; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -1558,6 +1547,7 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = VX2DSGURUH; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -1574,6 +1564,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -1584,13 +1575,11 @@ 7555FFA6242A565B00829871 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = VX2DSGURUH; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -1628,13 +1617,11 @@ 7555FFA7242A565B00829871 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = VX2DSGURUH; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; FRAMEWORK_SEARCH_PATHS = ( diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme index b9dbb68f..f316c961 100644 --- a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -1,6 +1,6 @@ [ObservationBulkModel] { - let start = Date(timeIntervalSince1970: TimeInterval(from.epochSeconds)) + // CMSensorRecorder only retains ~3 days of data and raises an uncatchable NSException + // ("startTime must be within 3 days of today") if `from` predates that window - clamp + // defensively rather than crashing when a catch-up collection reaches further back. + let earliestAvailable = Date().addingTimeInterval(-3 * 24 * 60 * 60 + 60) + let requestedStart = Date(timeIntervalSince1970: TimeInterval(from.epochSeconds)) + let start = max(requestedStart, earliestAvailable) let end = Date(timeIntervalSince1970: TimeInterval(to.epochSeconds)) guard start < end else { Napier.w("AccelerometerRecorderCollector::collect - start must be smaller than end! Start: \(start); End: \(end)") @@ -52,8 +57,7 @@ final class AccelerometerRecorderCollector: BackgroundAccelerometerCollector { let dict = ["x": accel.x, "y": accel.y, "z": accel.z] return ObservationBulkModel( data: dict, - timestamp: Int64(accDatum.startDate.timeIntervalSince1970), - instanceId: nil + timestamp: Int64(accDatum.startDate.timeIntervalSince1970) ) } } diff --git a/iosApp/iosApp/Style/MoreColor.swift b/iosApp/iosApp/Style/MoreColor.swift new file mode 100644 index 00000000..85725a63 --- /dev/null +++ b/iosApp/iosApp/Style/MoreColor.swift @@ -0,0 +1,49 @@ +// +// ColorExtension.swift +// iosApp +// +// Created by Jan Cortiel on 03.02.23. +// Copyright © 2023 Ludwig Boltzmann Institute for +// 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 +// (see https://www.apache.org/licenses/LICENSE-2.0 and +// https://commonsclause.com/). +// + +import SwiftUI + +extension Color { + static let more = Color.MoreColor() + + struct MoreColor { + let primaryDark = Color("PrimaryDark") + let primary = Color("Primary") + let primaryMedium = Color("PrimaryMedium") + let primaryLight200 = Color("PrimaryLight200") + let primaryLight = Color("PrimaryLight") + + let secondary = Color("Secondary") + let secondaryMedium = Color("SecondaryMedium") + let secondaryLight = Color("SecondaryLight") + + let textDefault = Color("Secondary") + let textInactive = Color("SecondaryMedium") + + let important = Color("Important") + let importantMedium = Color("ImportantMedium") + let importantLight = Color("ImportantLight") + + let approved = Color("Approved") + let approvedMedium = Color("ApprovedMedium") + let approvedLight = Color("ApprovedLight") + + let white = Color("White") + + // special elements + let divider = Color("PrimaryLight") + let mainBackground = Color("SecondaryLight") + } +} diff --git a/iosApp/iosApp/Views/Components/CheckboxField.swift b/iosApp/iosApp/Views/Components/CheckboxField.swift index cf8e2e1b..ca2df6d3 100644 --- a/iosApp/iosApp/Views/Components/CheckboxField.swift +++ b/iosApp/iosApp/Views/Components/CheckboxField.swift @@ -32,7 +32,7 @@ struct CheckboxField: View { Spacer() }.foregroundColor(.more.primaryLight) } - .foregroundColor(.white) + .foregroundColor(.more.white) .padding(.bottom, 7) .buttonStyle(.plain) .contentShape(Rectangle()) diff --git a/iosApp/iosApp/Views/Consent/ConsentView.swift b/iosApp/iosApp/Views/Consent/ConsentView.swift index 039867cf..e5b17688 100644 --- a/iosApp/iosApp/Views/Consent/ConsentView.swift +++ b/iosApp/iosApp/Views/Consent/ConsentView.swift @@ -93,8 +93,9 @@ struct ConsentView: View { mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), - reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true, - pollingTaskScheduler: nil + networkWatcher: nil, + pollingTaskScheduler: nil, + reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true ) let registration = RegistrationObservable(service: RegistrationService(shared: shared)) diff --git a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift index fbd1ece3..0d49fe17 100644 --- a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift +++ b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift @@ -86,7 +86,7 @@ extension ConsentViewModel: PermissionManagerObserver { // request here - it scopes to whichever subtypes the study actually needs, requests // only what's missing, and shows its own alert on decline. try? await AppDelegate.shared.observationFactory - .observation(type: ObservationTypeEnum.healthConnect.value)? + .observation(type: HealthConnectObservationType().observationType)? .updateObservationPermissions() if permissionManager.anyNeededPermissionDeclined() { AlertController.shared.openAlertDialog( diff --git a/iosApp/iosApp/Views/Login/LoginButton.swift b/iosApp/iosApp/Views/Login/LoginButton.swift index cc3983f0..d6ad9f2e 100644 --- a/iosApp/iosApp/Views/Login/LoginButton.swift +++ b/iosApp/iosApp/Views/Login/LoginButton.swift @@ -21,7 +21,7 @@ struct LoginButton: View { let action: () -> Void var body: some View { - MoreActionButton(backgroundColor: Color.pc.primary, disabled: .constant(disabled)) { + MoreActionButton(backgroundColor: Color.more.primary, disabled: .constant(disabled)) { action() } label: { Text("login_button") diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index 35b88f86..b470fac1 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -152,8 +152,9 @@ struct LoginView: View { mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), - reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true, - pollingTaskScheduler: nil + networkWatcher: nil, + pollingTaskScheduler: nil, + reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true ) let registration = RegistrationObservable(service: RegistrationService(shared: shared)) diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index 6a3e493e..32d77705 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -126,9 +126,9 @@ struct ScanQRCodeView: View { observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), networkWatcher: nil, + pollingTaskScheduler: nil, reminderNotificationSchedulingLimit: nil, - connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true, - pollingTaskScheduler: nil + connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true ) let registrationService = RegistrationService(shared: sharedContainer) diff --git a/iosApp/iosApp/Views/ObservationErrorListView.swift b/iosApp/iosApp/Views/ObservationErrorListView.swift index c5f589bf..a8bfa45c 100644 --- a/iosApp/iosApp/Views/ObservationErrorListView.swift +++ b/iosApp/iosApp/Views/ObservationErrorListView.swift @@ -25,7 +25,7 @@ struct ObservationErrorListView: View { HStack { Image(systemName: "exclamationmark.triangle") .font(.more.headline) - .foregroundColor(.pc.failure) + .foregroundColor(.more.important) .padding(.trailing, 4) BasicText(text: "\(error)!") } From b3333ace4ea018d61471edeef79aed46aefd1f09 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Mon, 24 Aug 2026 08:27:45 +0200 Subject: [PATCH 6/6] ios fastlane fix --- iosApp/fastlane/Fastfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/fastlane/Fastfile b/iosApp/fastlane/Fastfile index 63814cef..44a4e4f6 100644 --- a/iosApp/fastlane/Fastfile +++ b/iosApp/fastlane/Fastfile @@ -123,12 +123,12 @@ platform :ios do team_id: ENV["FASTLANE_TEAM_ID"], git_basic_authorization: ENV["MATCH_GIT_BASIC_AUTHORIZATION"], verbose: true, - keychain_name: "temp_keychain", + keychain_name: lane_context[:TEMP_KEYCHAIN_NAME], keychain_password: ENV["FASTLANE_KEYCHAIN_PASSWORD"] ) if ENV["CI"] unlock_keychain( - path: "~/Library/Keychains/temp_keychain-db", + path: lane_context[:TEMP_KEYCHAIN_PATH], password: ENV["FASTLANE_KEYCHAIN_PASSWORD"] )