diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..b753f1b7b
--- /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/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 9813b7380..4219b3733 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 {
@@ -63,7 +62,7 @@ android {
defaultConfig {
applicationId = "ac.at.lbg.dhp.more"
minSdk = 29
- targetSdk = 36
+ targetSdk = 37
versionCode = 37
versionName = "5.0.0"
}
@@ -148,6 +147,7 @@ android {
}
isMinifyEnabled = true
+ isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -215,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 02f420b88..5afbe3b48 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 d5db02a52..e63e1a140 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))
@@ -119,7 +123,10 @@ class MoreApplication : Application(), DefaultLifecycleObserver {
repositories,
sharedPreferences,
),
- AndroidDataRecorder()
+ AndroidDataRecorder(),
+ AndroidNetworkWatcher(context),
+ pollingTaskScheduler = AndroidPollingTaskScheduler(context),
+ isDebug = BuildConfig.DEBUG
)
shared = tempShared
tempShared.let { shared ->
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 c7f2f7656..42aed1229 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 b953b30de..a838b20e9 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 61f8ff4c4..0f26bf080 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 7d0b9508f..e86ab5b8c 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 5c1e5fa94..11e5725de 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 ba1b77d95..88a33b7b4 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 01d343a1c..e8e2ba8a3 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 000000000..d950a99f4
--- /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 000000000..256142cf3
--- /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 000000000..5e2f1ffc5
--- /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 000000000..29cd12a98
--- /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 000000000..6f6594a39
--- /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 313c92e61..b0b3c487a 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 0e9fb93e7..5938e3c0b 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 000000000..abe30fee8
--- /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/BlendedCare-Notification-Service-Extension/NotificationService.swift b/iosApp/BlendedCare-Notification-Service-Extension/NotificationService.swift
index 4cceae43e..0cee82c29 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/fastlane/Fastfile b/iosApp/fastlane/Fastfile
index 31aac9adc..44a4e4f65 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: !ENV["CI"].to_s.empty?,
+ 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(
@@ -94,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"]
)
diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj
index b2898f648..cc776bc98 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,8 @@
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 */; };
+ 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 */; };
@@ -109,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 */; };
@@ -123,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 */; };
@@ -137,7 +139,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 +284,7 @@
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 = ""; };
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 +294,8 @@
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 = ""; };
+ 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 = ""; };
@@ -327,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 = ""; };
@@ -341,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 = ""; };
@@ -357,6 +364,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 +486,7 @@
children = (
1F6A4E3A29F6C94B00F0247F /* Bluetooth */,
07EF6C4029B5E0C700CEF37D /* PermissionManager.swift */,
+ AE08ECBBA81140CBBD885A9E /* HealthKitManager.swift */,
1FE4446F29C849DC006AA11C /* Semaphore.swift */,
1F0026DE29CCA24F0034EF65 /* DataUploadManager.swift */,
ED6A7F3E29DC405B00E266EC /* FCMService.swift */,
@@ -491,6 +502,8 @@
1F988B3E2F2BA0CA0094F99F /* DailyBackgroundTask.swift */,
1F0026DC29CC8F710034EF65 /* BackgroundTaskHandler.swift */,
1FC4F87329D2B86100F65026 /* DataUploadBackgroundTask.swift */,
+ 9EEA5E1EE2CB59547A4884A1 /* PollingBackgroundTask.swift */,
+ 7EE8B6D852253640DD777AAA /* IOSPollingTaskScheduler.swift */,
);
path = BackgroundTasks;
sourceTree = "";
@@ -510,6 +523,7 @@
1F34796F29B8AFCB0030CA15 /* Observations */ = {
isa = PBXGroup;
children = (
+ 1F6F8F44302C3AD40012A1A9 /* HealthObservationCollectors */,
1FA044452F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift */,
1F34797029B8AFE10030CA15 /* IOSObservationFactory.swift */,
1F34797429B8BECB0030CA15 /* AccelerometerObservation.swift */,
@@ -517,7 +531,7 @@
1FE4447129C85D94006AA11C /* IOSDataRecorder.swift */,
07D9046729C85166003D2912 /* GPSObservation.swift */,
EDACF62829D2FB200032327B /* PolarVerityHeartRateObservation.swift */,
- 1F5A248C29C893B3008140CF /* AccelerometerBackgroundObservation.swift */,
+ 1F5A248C29C893B3008140CF /* AccelerometerRecorderCollector.swift */,
1F7F094D29D40EC800081B88 /* ObservationDataCollector.swift */,
1F8EA2D22A0CC7D600F32602 /* ObservationActionDelegate.swift */,
);
@@ -556,13 +570,6 @@
path = GarminConnect;
sourceTree = "";
};
- 1F60BFC92FA8A423007CD41A /* PC_Components */ = {
- isa = PBXGroup;
- children = (
- );
- path = PC_Components;
- sourceTree = "";
- };
1F6A4E3A29F6C94B00F0247F /* Bluetooth */ = {
isa = PBXGroup;
children = (
@@ -581,6 +588,15 @@
path = WebView;
sourceTree = "";
};
+ 1F6F8F44302C3AD40012A1A9 /* HealthObservationCollectors */ = {
+ isa = PBXGroup;
+ children = (
+ 1F6F8F40302C3ACC0012A1A9 /* HeartRateHealthConnectCollector.swift */,
+ 1F6F8F41302C3ACC0012A1A9 /* StepsHealthConnectCollector.swift */,
+ );
+ path = HealthObservationCollectors;
+ sourceTree = "";
+ };
1F8847B829914BD50023EF10 /* Components */ = {
isa = PBXGroup;
children = (
@@ -716,7 +732,6 @@
1F9C3E8B298AAC0000B9AC82 /* Views */ = {
isa = PBXGroup;
children = (
- 1F60BFC92FA8A423007CD41A /* PC_Components */,
1F43DE1F2EC6136700B6F07B /* GarminConnect */,
1F1E45CF2E7842E300C82016 /* Registration */,
1F8937892BFF1EB20083D20E /* ObservationErrors */,
@@ -763,7 +778,6 @@
1F9DB1A1298CF57600DBB7DB /* Style */ = {
isa = PBXGroup;
children = (
- 1F9DB19F298CF44000DBB7DB /* MoreColor.swift */,
1F8847C72991535B0023EF10 /* MoreFontWeight.swift */,
1F8847C9299154120023EF10 /* MoreFont.swift */,
1F8847D3299158BC0023EF10 /* MoreImage.swift */,
@@ -774,6 +788,7 @@
1F8847EB2992C3240023EF10 /* MoreFrame.swift */,
1F13BA4A299398FD00938C1E /* MoreTextStyle.swift */,
1F13BA4C29939E4F00938C1E /* MoreListStyleEdgeInsets.swift */,
+ 1FCD64133038512500B38B88 /* MoreColor.swift */,
);
path = Style;
sourceTree = "";
@@ -1052,7 +1067,7 @@
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1540;
- LastUpgradeCheck = 1530;
+ LastUpgradeCheck = 2660;
ORGANIZATIONNAME = "Redlink GmbH";
TargetAttributes = {
1FC9574A2C072B7900EB92D6 = {
@@ -1164,8 +1179,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 +1188,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 +1218,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 */,
@@ -1300,15 +1318,18 @@
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 */,
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 */,
@@ -1318,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 */,
@@ -1354,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;
@@ -1394,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;
@@ -1461,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;
@@ -1484,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";
@@ -1528,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;
@@ -1544,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";
@@ -1554,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 = (
@@ -1598,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 b9dbb68f4..f316c9610 100644
--- a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme
+++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme
@@ -1,6 +1,6 @@
Void) {
print("Notification Received: \(userInfo)")
AppDelegate.shared.notificationManager.handleNotificationDataAsync(data: userInfo.notNilStringDictionary())
diff --git a/iosApp/iosApp/BackgroundTasks/IOSPollingTaskScheduler.swift b/iosApp/iosApp/BackgroundTasks/IOSPollingTaskScheduler.swift
new file mode 100644
index 000000000..c3e42d73e
--- /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 000000000..4f22ed983
--- /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 5b0910403..1f83c41b9 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 fd63a8a9e..626ab17df 100644
--- a/iosApp/iosApp/InfoPlist.xcstrings
+++ b/iosApp/iosApp/InfoPlist.xcstrings
@@ -19,13 +19,13 @@
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "$(PRODUCT_NAME) benötigt Zugriff auf Bluetooth, um unterstützte Gesundheitsgeräte zu finden und zu verbinden."
+ "value" : "More benötigt Zugriff auf Bluetooth, um unterstützte Gesundheitsgeräte zu finden und zu verbinden."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "$(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health devices"
+ "value" : "More needs access to Bluetooth to find and connect to supported health devices"
}
}
}
@@ -36,13 +36,13 @@
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "$(PRODUCT_NAME) benötigt Zugriff auf Bluetooth, um unterstützte Gesundheitsgeräte zu finden und zu verbinden."
+ "value" : "More benötigt Zugriff auf Bluetooth, um unterstützte Gesundheitsgeräte zu finden und zu verbinden."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "$(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health devices"
+ "value" : "More needs access to Bluetooth to find and connect to supported health devices"
}
}
}
@@ -53,13 +53,25 @@
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "$(PRODUCT_NAME) benötigt Zugriff auf deine Kamera, um QR-Codes zu scannen."
+ "value" : "More benötigt Zugriff auf deine Kamera, um QR-Codes zu scannen."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "$(PRODUCT_NAME) needs your camera to scan QR-Code"
+ "value" : "More needs your camera to scan QR-Code"
+ }
+ }
+ }
+ },
+ "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 "
}
}
}
@@ -70,13 +82,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"
}
}
}
@@ -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 6f4f62fa0..000000000
--- 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 000000000..9ca0f6466
--- /dev/null
+++ b/iosApp/iosApp/Observations/AccelerometerRecorderCollector.swift
@@ -0,0 +1,64 @@
+//
+// 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] {
+ // 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)")
+ 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)
+ )
+ }
+ }
+}
diff --git a/iosApp/iosApp/Observations/HealthObservationCollectors/HeartRateHealthConnectCollector.swift b/iosApp/iosApp/Observations/HealthObservationCollectors/HeartRateHealthConnectCollector.swift
new file mode 100644
index 000000000..9cb47ebba
--- /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 000000000..24af886c7
--- /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 948ae53c5..61bf9c6db 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 0cdf6a6d1..68db5445e 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/FCMService.swift b/iosApp/iosApp/Services/FCMService.swift
index 7eacfa05c..30c4000de 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/iosApp/iosApp/Services/HealthKitManager.swift b/iosApp/iosApp/Services/HealthKitManager.swift
new file mode 100644
index 000000000..e93271105
--- /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/Views/Consent/ConsentView.swift b/iosApp/iosApp/Views/Consent/ConsentView.swift
index 30c89ab5c..e5b176889 100644
--- a/iosApp/iosApp/Views/Consent/ConsentView.swift
+++ b/iosApp/iosApp/Views/Consent/ConsentView.swift
@@ -93,7 +93,10 @@ struct ConsentView: View {
mainBluetoothConnector: IOSBluetoothConnector(),
observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults),
dataRecorder: IOSDataRecorder(),
- reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection()
+ networkWatcher: nil,
+ pollingTaskScheduler: nil,
+ 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/Consent/ConsentViewModel.swift b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift
index ccd723cda..0d49fe178 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: HealthConnectObservationType().observationType)?
+ .updateObservationPermissions()
if permissionManager.anyNeededPermissionDeclined() {
AlertController.shared.openAlertDialog(
model:
diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift
index b7a4831d7..b470fac19 100644
--- a/iosApp/iosApp/Views/Login/LoginView.swift
+++ b/iosApp/iosApp/Views/Login/LoginView.swift
@@ -152,7 +152,10 @@ struct LoginView: View {
mainBluetoothConnector: IOSBluetoothConnector(),
observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults),
dataRecorder: IOSDataRecorder(),
- reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection()
+ networkWatcher: nil,
+ pollingTaskScheduler: nil,
+ 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 6c567d399..32d777050 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,
+ pollingTaskScheduler: nil,
reminderNotificationSchedulingLimit: nil,
- connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection()
+ connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection(), isDebug: true
+
)
let registrationService = RegistrationService(shared: sharedContainer)
ScanQRCodeView(model: LoginViewModel(registration: registrationService))
diff --git a/iosApp/iosApp/iosApp.entitlements b/iosApp/iosApp/iosApp.entitlements
index 57c50cc4c..e810fcf59 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 000000000..12958c12e
--- /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 526943c5a..cf9661822 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 02881cd60..1f4e986e0 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 42ec26465..e4d602cb0 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 000000000..74fffd3f5
--- /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 000000000..e32cf9fcf
--- /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 000000000..db0fab6ab
--- /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/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 000000000..1e0e905b0
--- /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/Constants.kt b/shared/src/commonMain/kotlin/io/redlink/more/Constants.kt
new file mode 100644
index 000000000..1bd6d6735
--- /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 6b93d699b..5ad723b4d 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,12 +32,17 @@ 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
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
import io.redlink.more.services.notification.NotificationActionObserver
@@ -70,9 +79,13 @@ 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()
+ konnectionInstance().observeHasConnection(),
+ val isDebug: Boolean = false,
+
) : NotificationActionObserver, ExitStudyListener, AutoCloseable {
val deeplinkManager: DeeplinkManager = DeeplinkManagerImpl(repositories, observationFactory)
val endpointRepository: EndpointRepository = EndpointRepositoryImpl(sharedStorageRepository)
@@ -80,8 +93,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,
@@ -108,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 ->
@@ -156,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) {
@@ -194,6 +243,7 @@ open class Shared(
observationManager.updateTaskStates()
observationService.scheduleObservationReminder()
notificationManager.downloadMissedNotifications()
+ EventBus.tryPublish(AppEvent.ScheduleHaveUpdated)
}
override fun updateStudy(
@@ -347,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 63f54302f..1f3240b60 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 c09ffe75e..de769dad8 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 000000000..b52ace6ff
--- /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 000000000..eedd387c0
--- /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 000000000..6fb717fd7
--- /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 c3e4efad4..0806fabb9 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 1aa0be4d5..5901e411d 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