From da1a1b2a305ddab33b82d7ecf189ab67e76a2b64 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Wed, 18 Mar 2026 09:44:22 +0100 Subject: [PATCH 1/7] #369: Implemented App Usage observation Other changes: - Better observation initalization process for less memory usage - Added a long running observation storage possibility, so data are not sent right away --- .../more/app/android/MoreApplication.kt | 23 +- .../android/activities/ContentViewModel.kt | 4 +- .../Views.kt => OnAppearDisappear.kt} | 23 +- .../bluetooth/BluetoothViewModel.kt | 16 +- .../CompletedSchedulesView.kt | 40 +- .../android/activities/consent/ConsentView.kt | 93 +++-- .../activities/consent/ConsentViewModel.kt | 22 +- .../consent/composables/ConsentButtons.kt | 8 + .../activities/dashboard/DashboardView.kt | 41 +- .../dashboard/filter/DashboardFilterView.kt | 187 ++++----- .../filter/DashboardFilterViewModel.kt | 2 +- .../app/android/activities/info/InfoView.kt | 317 ++++++++------- .../android/activities/info/InfoViewModel.kt | 4 +- .../leaveStudy/LeaveStudyConfirmView.kt | 142 +++---- .../activities/leaveStudy/LeaveStudyView.kt | 130 +++--- .../leaveStudy/LeaveStudyViewModel.kt | 30 +- .../activities/login/LoginViewModel.kt | 18 +- .../android/activities/main/MainActivity.kt | 3 +- .../notification/NotificationView.kt | 76 ++-- .../filter/NotificationFilterView.kt | 69 ++-- .../filter/NotificationFilterViewModel.kt | 2 +- .../garmin/GarminConnectActivity.kt | 4 +- .../garmin/GarminConnectViewModel.kt | 6 +- .../limeSurvey/LimeSurveyViewModel.kt | 4 +- .../questionnaire/QuestionnaireView.kt | 66 ++- .../runningSchedules/RunningSchedulesView.kt | 39 +- .../activities/setting/SettingsView.kt | 107 +++-- .../activities/setting/SettingsViewModel.kt | 12 +- .../studyDetails/StudyDetailsView.kt | 86 ++-- .../ObservationDetailsView.kt | 122 +++--- .../ObservationDetailsViewModel.kt | 15 +- .../activities/subcomponents/ExitButton.kt | 28 +- .../tasks/ObservationActionButton.kt | 110 +++-- .../activities/tasks/TaskDetailsView.kt | 211 +++++----- .../activities/tasks/TaskDetailsViewModel.kt | 2 - .../app/android/activities/web/WebClient.kt | 5 +- .../AndroidObservationDataManager.kt | 6 +- .../observations/AndroidObservationFactory.kt | 46 ++- .../AndroidObservationPermissionObserver.kt | 80 ++++ .../observations/GPS/GPSObservation.kt | 17 +- .../HR/PolarHeartRateObservation.kt | 5 +- .../observations/ObservationExtension.kt | 14 +- .../services/ObservationRecordingService.kt | 69 ++-- .../shared_composables/MessageAlertDialog.kt | 12 +- .../MoreBackgroundComposable.kt | 8 +- gradle.properties | 13 +- iosApp/iosApp.xcodeproj/project.pbxproj | 8 + iosApp/iosApp/AppDelegate.swift | 14 +- iosApp/iosApp/Info.plist | 220 +++++----- iosApp/iosApp/InfoPlist.xcstrings | 171 ++++++++ .../iosApp/Observations/GPSObservation.swift | 68 +++- .../Observations/IOSObservationFactory.swift | 22 +- .../IOSObservationPermissionObserver.swift | 137 +++++++ .../Resources/Strings/Localizable.xcstrings | 4 - .../iosApp/Services/PermissionManager.swift | 98 +++-- iosApp/iosApp/Utils/Napier.swift | 44 +- .../CompletedSchedules.swift | 7 +- .../iosApp/Views/Components/ExitButton.swift | 2 +- .../Views/Components/MoreAlertDialog.swift | 35 +- .../Components/WebView/WebViewViewModel.swift | 1 + iosApp/iosApp/Views/Consent/ConsentView.swift | 7 +- .../Views/Consent/ConsentViewModel.swift | 49 +-- .../Views/Dashboard/DashboardView.swift | 8 +- .../GarminConnect/GarminConnectView.swift | 4 + .../GarminConnectViewModel.swift | 2 +- iosApp/iosApp/Views/Info/InfoView.swift | 12 +- iosApp/iosApp/Views/Info/InfoViewModel.swift | 2 +- .../Views/LeaveStudy/LeaveStudyView.swift | 9 +- iosApp/iosApp/Views/Login/LoginView.swift | 7 +- .../iosApp/Views/Login/LoginViewModel.swift | 2 +- .../iosApp/Views/Login/ScanQRCodeView.swift | 5 +- .../Filter/NotificationFilterView.swift | 10 +- .../Filter/NotificationFilterViewModel.swift | 1 - .../Views/Notification/NotificationView.swift | 7 + .../Notification/NotificationViewModel.swift | 2 +- .../Registration/RegistrationObservable.swift | 8 +- .../RunningSchedules/RunningSchedules.swift | 7 +- .../Schedule/List/ObservationButton.swift | 65 ++- .../Views/Schedule/ScheduleViewModel.swift | 2 +- .../iosApp/Views/Settings/SettingsView.swift | 53 ++- .../Views/Settings/SettingsViewModel.swift | 76 +++- .../StudyDetails/StudyDetailsViewModel.swift | 2 +- .../Views/StudyStates/StudyClosedView.swift | 2 +- iosApp/iosApp/iOSApp.swift | 39 +- shared/build.gradle.kts | 14 +- .../io/redlink/more/util/PlatformUtils.kt | 18 + .../kotlin/io/redlink/more/Shared.kt | 18 +- .../io/redlink/more/database/AppDatabase.kt | 19 +- .../redlink/more/database/DatabaseManager.kt | 3 +- .../dao/AggregatedObservationDataDao.kt | 59 +++ .../redlink/more/database/dao/ScheduleDao.kt | 3 + .../AggregatedObservationDataEntity.kt | 123 ++++++ .../entities/ObservationDataEntity.kt | 4 +- .../more/database/migrations/Migration_2_3.kt | 22 + .../AggregatedObservationDataRepository.kt | 39 ++ ...AggregatedObservationDataRepositoryImpl.kt | 70 ++++ .../database/repository/MainRepository.kt | 12 + .../database/repository/MainRepositoryImpl.kt | 14 + .../database/repository/ScheduleRepository.kt | 2 + .../repository/ScheduleRepositoryImpl.kt | 13 +- .../more/{ => dialog}/AlertController.kt | 14 +- .../redlink/more/dialog/AlertDialogModel.kt | 66 +++ .../redlink/more/logging/EventCollection.kt | 52 +++ .../io/redlink/more/logging/KMMLogger.kt | 44 ++ .../redlink/more/{ => logging}/NapierProxy.kt | 6 +- .../redlink/more/models/AlertDialogModel.kt | 32 -- .../io/redlink/more/models/ScheduleState.kt | 1 + .../more/navigation/model/NavigationRoute.kt | 61 ++- .../redlink/more/observations/Observation.kt | 86 +++- .../more/observations/ObservationBulkModel.kt | 2 +- .../more/observations/ObservationFactory.kt | 99 ++++- .../appUsage/AppUsageObservation.kt | 380 ++++++++++++++++++ .../observations/appUsage/model/EventModel.kt | 20 + .../model/InstantObservationPayload.kt | 21 + .../observations/appUsage/model/LogEvent.kt | 108 +++++ .../appUsage/model/RangeObservationPayload.kt | 22 + .../DefaultLongRunningObservationStorage.kt | 128 ++++++ .../InMemoryLongRunningObservationStorage.kt | 89 ++++ .../LongRunningObservationStorage.kt | 65 +++ .../AppUsageObservationType.kt | 15 + .../services/store/PermissionRepository.kt | 33 ++ .../store/PermissionRepositoryImpl.kt | 49 +++ .../kotlin/io/redlink/more/util/KMMLogger.kt | 22 - .../io/redlink/more/util/PlatformUtils.kt | 14 + .../redlink/more/viewModels/CoreViewModel.kt | 19 +- .../io/redlink/more/viewModels/ViewManager.kt | 29 ++ .../BluetoothController.kt | 5 + .../dashboard/CoreDashboardFilterViewModel.kt | 5 + .../CoreGarminConnectViewModel.kt | 16 + .../limeSurvey/CoreLimeSurveyViewModel.kt | 10 + .../viewModels/login/CoreLoginViewModel.kt | 5 + .../CoreNotificationFilterViewModel.kt | 5 + .../CoreNotificationViewModel.kt | 5 + .../CoreObservationDetailsViewModel.kt | 4 + .../permission/CoreConsentViewModel.kt | 5 + .../schedules/CoreScheduleViewModel.kt | 10 + .../settings/CoreSettingsViewModel.kt | 104 ++++- .../simpleQuestion/QuestionCoreViewModel.kt | 16 + .../CoreBluetoothViewModel.kt | 2 + .../studydetails/CoreStudyDetailsViewModel.kt | 7 +- .../CoreTaskCompletionBarViewModel.kt | 3 + .../tasks/CoreTaskDetailsViewModel.kt | 5 + .../moko-resources/base/strings.xml | 28 +- .../commonMain/moko-resources/de/strings.xml | 31 +- .../io/redlink/more/mocks/DatabaseMock.kt | 12 + .../io/redlink/more/mocks/RepositoryMocks.kt | 98 ++++- .../io/redlink/more/mocks/ServiceMocks.kt | 52 +++ ...MemoryLongRunningObservationStorageTest.kt | 220 ++++++++++ .../appUsage/AppUsageObservationTest.kt | 253 ++++++++++++ .../io/redlink/more/extensions/toString.kt | 2 +- .../io/redlink/more/util/PlatformUtils.kt | 28 ++ 151 files changed, 4938 insertions(+), 1452 deletions(-) rename androidApp/src/main/java/io/redlink/more/app/android/activities/{dashboard/Views.kt => OnAppearDisappear.kt} (54%) create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationPermissionObserver.kt create mode 100644 iosApp/iosApp/InfoPlist.xcstrings create mode 100644 iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift create mode 100644 shared/src/androidMain/kotlin/io/redlink/more/util/PlatformUtils.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/dao/AggregatedObservationDataDao.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/entities/AggregatedObservationDataEntity.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_2_3.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepository.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepositoryImpl.kt rename shared/src/commonMain/kotlin/io/redlink/more/{ => dialog}/AlertController.kt (69%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertDialogModel.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/logging/EventCollection.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/logging/KMMLogger.kt rename shared/src/commonMain/kotlin/io/redlink/more/{ => logging}/NapierProxy.kt (86%) delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/EventModel.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/InstantObservationPayload.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/RangeObservationPayload.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/DefaultLongRunningObservationStorage.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/InMemoryLongRunningObservationStorage.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/LongRunningObservationStorage.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AppUsageObservationType.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepository.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepositoryImpl.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/util/PlatformUtils.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/observations/InMemoryLongRunningObservationStorageTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt create mode 100644 shared/src/iosMain/kotlin/io/redlink/more/util/PlatformUtils.kt diff --git a/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt b/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt index 139d1bf47..d5db02a52 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 @@ -30,9 +30,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.logging.napierDebugBuild import io.redlink.more.models.NotificationTextLocalization -import io.redlink.more.napierDebugBuild import io.redlink.more.services.store.SharedPreferencesRepository +import io.redlink.more.viewModels.ViewManager /** * Main Application class of the project. @@ -42,6 +43,9 @@ class MoreApplication : Application(), DefaultLifecycleObserver { super.onCreate() napierDebugBuild(FirebaseCrashlyticsAntilog()) napierDebugBuild() + + firebaseAnalytics = FirebaseAnalytics.getInstance(this) + appContext = this packagePath = this.packageName appName = this.getString(R.string.app_name) @@ -60,12 +64,14 @@ class MoreApplication : Application(), DefaultLifecycleObserver { override fun onResume(owner: LifecycleOwner) { super.onResume(owner) Napier.i { "App is in the foreground..." } + ViewManager.appIsInForeground(true) shared?.updateData(true) } override fun onPause(owner: LifecycleOwner) { super.onPause(owner) Napier.i { "App is in the background..." } + ViewManager.appIsInForeground(false) shared?.updateData(false) } @@ -100,16 +106,23 @@ class MoreApplication : Application(), DefaultLifecycleObserver { val database: AppDatabase = getRoomDatabase(getDatabaseBuilder(context)) val repositories = MainRepositoryImpl(database) val dataManager = AndroidObservationDataManager(context, repositories) - shared = Shared( + val sharedPreferences = SharedPreferencesRepository(context) + val tempShared = Shared( LocalPushNotificationService(context), repositories, - SharedPreferencesRepository(context), + sharedPreferences, dataManager, androidBluetoothConnector, - AndroidObservationFactory(context, dataManager, repositories), + AndroidObservationFactory( + context, + dataManager, + repositories, + sharedPreferences, + ), AndroidDataRecorder() ) - shared?.let { shared -> + shared = tempShared + tempShared.let { shared -> shared.deeplinkManager.setProtocol(Shared.PROTOCOL.toString(context)) shared.deeplinkManager.setHost(applicationId) // applicationId is needed instead of the shared HOST, as this is necessary for the NavController in Android } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentViewModel.kt index 1cf36fb03..bae6dba98 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentViewModel.kt @@ -20,12 +20,12 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import io.github.aakira.napier.Napier -import io.redlink.more.AlertController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.main.MainActivity import io.redlink.more.app.android.extensions.showNewActivityAndClearStack import io.redlink.more.app.android.workers.ScheduleUpdateWorker -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel import io.redlink.more.registration.RegistrationService import io.redlink.more.scopes.Scope import io.redlink.more.services.notification.NotificationManager diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/Views.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/OnAppearDisappear.kt similarity index 54% rename from androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/Views.kt rename to androidApp/src/main/java/io/redlink/more/app/android/activities/OnAppearDisappear.kt index 485b30a5c..b9f10734a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/Views.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/OnAppearDisappear.kt @@ -8,12 +8,23 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.app.android.activities.dashboard -import io.redlink.more.app.android.R -import io.redlink.more.app.android.extensions.stringResource +package io.redlink.more.app.android.activities -enum class Views(val tabPosition: Int, val tabText: String) { - SCHEDULE(tabPosition = 0, tabText = stringResource(R.string.more_main_tab_schedule)), - MODULES(tabPosition = 1, tabText = stringResource(R.string.more_main_tab_observations)); +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect + +@Composable +fun OnAppearDisappear( + onAppear: () -> Unit, + onDisappear: () -> Unit, + content: @Composable () -> Unit +) { + DisposableEffect(Unit) { + onAppear() + onDispose { + onDisappear() + } + } + content() } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothViewModel.kt index e4426f63b..65e436f34 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothViewModel.kt @@ -14,12 +14,12 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import io.redlink.more.AlertController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.services.sensorsListener.BluetoothStateListener import io.redlink.more.app.android.services.sensorsListener.GPSStateListener import io.redlink.more.database.entities.BluetoothDeviceEntity -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel import io.redlink.more.scopes.Scope import io.redlink.more.services.bluetooth.BluetoothStateManagement import io.redlink.more.viewModels.ViewManager @@ -29,7 +29,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class BluetoothViewModel : ViewModel() { - private val coreBluetoothViewModel = CoreBluetoothViewModel( + val coreViewModel = CoreBluetoothViewModel( MoreApplication.shared!!.observationFactory, MoreApplication.shared!!.bluetoothController ) @@ -86,7 +86,7 @@ class BluetoothViewModel : ViewModel() { } viewModelScope.launch(Dispatchers.IO) { - coreBluetoothViewModel.devicesNeededToConnectTo.collect { + coreViewModel.devicesNeededToConnectTo.collect { withContext(Dispatchers.Main) { neededDevices.clear() neededDevices.addAll(MoreApplication.shared!!.observationFactory.bleDevicesNeeded()) @@ -113,21 +113,21 @@ class BluetoothViewModel : ViewModel() { fun viewDidAppear() { ViewManager.bleViewOpen(true) - coreBluetoothViewModel.viewDidAppear() + coreViewModel.viewOpened() } fun viewDidDisappear() { - coreBluetoothViewModel.viewDidDisappear() ViewManager.bleViewOpen(false) + coreViewModel.viewClosed() } fun connectToDevice(device: BluetoothDeviceEntity) { Scope.launch { - coreBluetoothViewModel.connectToDevice(device) + coreViewModel.connectToDevice(device) } } fun disconnectFromDevice(device: BluetoothDeviceEntity) { - coreBluetoothViewModel.disconnectFromDevice(device) + coreViewModel.disconnectFromDevice(device) } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/completedSchedules/CompletedSchedulesView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/completedSchedules/CompletedSchedulesView.kt index bd2e133e2..777213031 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/completedSchedules/CompletedSchedulesView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/completedSchedules/CompletedSchedulesView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel import io.redlink.more.app.android.activities.dashboard.schedule.list.ScheduleListView import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel @@ -32,25 +33,30 @@ fun CompletedSchedulesView( navController: NavController, taskCompletionBarViewModel: TaskCompletionBarViewModel ) { - Column( - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - ) { - ScheduleListHeader( - viewModel = viewModel, - navController = navController, - taskCompletionBarViewModel = taskCompletionBarViewModel - ) - Spacer(modifier = Modifier.height(10.dp)) - Column { - ScheduleListView( + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { + + Column( + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + ScheduleListHeader( + viewModel = viewModel, navController = navController, - scheduleViewModel = viewModel, - showButton = false + taskCompletionBarViewModel = taskCompletionBarViewModel ) + Spacer(modifier = Modifier.height(10.dp)) + Column { + ScheduleListView( + navController = navController, + scheduleViewModel = viewModel, + showButton = false + ) + } } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentView.kt index 57c354a9e..0505250ad 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentView.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.consent.composables.ConsentButtons import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.Accordion @@ -42,51 +43,55 @@ fun ConsentView(registrationService: RegistrationService) { val model = remember { ConsentViewModel(registrationService) } val permissions by model.coreModel.permissions.collectAsStateWithLifecycle() permissions?.let { permissionModel -> - LazyColumn( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(0.9f) - ) { - item { - Text( - text = permissionModel.studyTitle, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - color = MoreColors.Primary, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.padding(8.dp)) - AccordionReadMore( - title = getStringResource(id = R.string.participant_information), - description = permissionModel.studyParticipantInfo, - modifier = Modifier - .fillMaxWidth() - ) - } - items(permissionModel.consentInfo) { consentInfo -> - Accordion( - title = if (permissionModel.consentInfo.indexOf(consentInfo) == 0) - permissionModel.studyTitle - else - consentInfo.title, - description = consentInfo.info, - hasCheck = true, - hasPreview = (permissionModel.consentInfo.indexOf(consentInfo) == 0) - ) - } - item { - Spacer(modifier = Modifier.height(40.dp)) - } + OnAppearDisappear( + { model.coreModel.viewDidAppear() }, + { model.coreModel.viewDidDisappear() }) { + LazyColumn( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(0.9f) + ) { + item { + Text( + text = permissionModel.studyTitle, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = MoreColors.Primary, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.padding(8.dp)) + AccordionReadMore( + title = getStringResource(id = R.string.participant_information), + description = permissionModel.studyParticipantInfo, + modifier = Modifier + .fillMaxWidth() + ) + } + items(permissionModel.consentInfo) { consentInfo -> + Accordion( + title = if (permissionModel.consentInfo.indexOf(consentInfo) == 0) + permissionModel.studyTitle + else + consentInfo.title, + description = consentInfo.info, + hasCheck = true, + hasPreview = (permissionModel.consentInfo.indexOf(consentInfo) == 0) + ) + } + item { + Spacer(modifier = Modifier.height(40.dp)) + } - item { - Box( - contentAlignment = Alignment.BottomCenter, - modifier = Modifier - .padding(bottom = 10.dp) - ) { - ConsentButtons(model = model) + item { + Box( + contentAlignment = Alignment.BottomCenter, + modifier = Modifier + .padding(bottom = 10.dp) + ) { + ConsentButtons(model = model) + } } } } 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 021f17fda..9a7edcdb7 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 @@ -12,12 +12,14 @@ package io.redlink.more.app.android.activities.consent import android.content.Context import androidx.lifecycle.ViewModel -import io.redlink.more.AlertController +import dev.icerock.moko.resources.desc.Raw +import dev.icerock.moko.resources.desc.StringDesc import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getSecureID import io.redlink.more.app.android.extensions.stringResource -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel import io.redlink.more.registration.RegistrationService import io.redlink.more.viewModels.permission.CoreConsentViewModel @@ -36,10 +38,10 @@ class ConsentViewModel( fun openPermissionDeniedAlertDialog(context: Context) { AlertController.openAlertDialog( AlertDialogModel( - title = stringResource(R.string.required_permissions_not_granted_title), - message = stringResource(R.string.required_permission_not_granted_message), - confirmLabel = stringResource(R.string.proceed_to_settings_button), - cancelLabel = stringResource(R.string.proceed_without_granting_button), + title = StringDesc.Raw(stringResource(R.string.required_permissions_not_granted_title)), + message = StringDesc.Raw(stringResource(R.string.required_permission_not_granted_message)), + confirmLabel = StringDesc.Raw(stringResource(R.string.proceed_to_settings_button)), + cancelLabel = StringDesc.Raw(stringResource(R.string.proceed_without_granting_button)), onConfirm = { MoreApplication.openSettings.value = true }, @@ -52,10 +54,10 @@ class ConsentViewModel( fun openNotificationPermissionDeniedAlertDialog(context: Context) { AlertController.openAlertDialog( AlertDialogModel( - title = stringResource(R.string.notification_permission_not_granted_title), - message = stringResource(R.string.notification_permission_not_granted_message), - confirmLabel = stringResource(R.string.proceed_to_settings_button), - cancelLabel = stringResource(R.string.proceed_without_granting_button), + title = StringDesc.Raw(stringResource(R.string.notification_permission_not_granted_title)), + message = StringDesc.Raw(stringResource(R.string.notification_permission_not_granted_message)), + confirmLabel = StringDesc.Raw(stringResource(R.string.proceed_to_settings_button)), + cancelLabel = StringDesc.Raw(stringResource(R.string.proceed_without_granting_button)), onConfirm = { MoreApplication.openSettings.value = true }, 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 1bff25f02..d47a4ae0c 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 @@ -34,12 +34,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.aakira.napier.Napier import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.consent.ConsentViewModel import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.observations.PermissionUtils 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 @Composable fun ConsentButtons(model: ConsentViewModel) { @@ -82,6 +86,7 @@ fun ConsentButtons(model: ConsentViewModel) { ) { Button( onClick = { + Napier.event(LogEvent.BUTTON_PRESS, "Consent approved") checkAndRequestPermissions(context, launcher, model) }, colors = ButtonDefaults @@ -99,6 +104,7 @@ fun ConsentButtons(model: ConsentViewModel) { Button( onClick = { + Napier.event(LogEvent.BUTTON_PRESS, "Consent declined") model.decline() }, colors = ButtonDefaults @@ -148,6 +154,8 @@ fun checkAndRequestPermissions( ?: emptySet() ) + permissions.removeAll(AppUsageObservationType().sensorPermissions) + val hasBackgroundLocationPermission = permissions.contains(Manifest.permission.ACCESS_BACKGROUND_LOCATION) if (hasBackgroundLocationPermission) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardView.kt index 42776cebf..5c8561084 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel import io.redlink.more.app.android.activities.dashboard.schedule.list.ScheduleListView import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel @@ -32,25 +33,29 @@ fun DashboardView( scheduleViewModel: ScheduleViewModel, taskCompletionBarViewModel: TaskCompletionBarViewModel ) { - Column( - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - ) { - ScheduleListHeader( - viewModel = scheduleViewModel, - navController = navController, - taskCompletionBarViewModel = taskCompletionBarViewModel - ) - Spacer(modifier = Modifier.height(10.dp)) - Column { - ScheduleListView( - navController, - scheduleViewModel, - showButton = true + OnAppearDisappear( + { scheduleViewModel.coreViewModel.viewOpened() }, + { scheduleViewModel.coreViewModel.viewClosed() }) { + Column( + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + ScheduleListHeader( + viewModel = scheduleViewModel, + navController = navController, + taskCompletionBarViewModel = taskCompletionBarViewModel ) + Spacer(modifier = Modifier.height(10.dp)) + Column { + ScheduleListView( + navController, + scheduleViewModel, + showButton = true + ) + } } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt index 33a1765d7..9ecfe0190 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.app.android.shared_composables.HeaderDescription @@ -41,111 +42,115 @@ import io.redlink.more.viewModels.schedules.CoreScheduleViewModel @Composable fun DashboardFilterView(coreScheduleViewModel: CoreScheduleViewModel) { val viewModel = remember { DashboardFilterViewModel(coreScheduleViewModel.coreFilterModel) } - LazyColumn { - item { - HeaderTitle( - title = stringResource(R.string.more_filter_set_duration), - modifier = Modifier.padding(top = 20.dp) - ) - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) - } + OnAppearDisappear( + { viewModel.coreViewModel.viewOpened() }, + { viewModel.coreViewModel.viewClosed() }) { + LazyColumn { + item { + HeaderTitle( + title = stringResource(R.string.more_filter_set_duration), + modifier = Modifier.padding(top = 20.dp) + ) + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) + } - itemsIndexed(viewModel.currentDateFilter.entries.sortedBy { it.key.sortIndex }) { _, item -> - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (item.value) - IconInline( - icon = Icons.Rounded.Done, - color = MoreColors.Approved, - contentDescription = getStringResource(id = R.string.more_filter_selected) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { viewModel.toggleDateFilter(item.key) }) - .padding(4.dp) + itemsIndexed(viewModel.currentDateFilter.entries.sortedBy { it.key.sortIndex }) { _, item -> + Row( + verticalAlignment = Alignment.CenterVertically, ) { - HeaderDescription( - description = viewModel.dateFilters[item.key] - ?: getStringResource(id = R.string.more_filter_all), - color = if (item.value) MoreColors.Primary else MoreColors.Secondary - ) + if (item.value) + IconInline( + icon = Icons.Rounded.Done, + color = MoreColors.Approved, + contentDescription = getStringResource(id = R.string.more_filter_selected) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.toggleDateFilter(item.key) }) + .padding(4.dp) + ) { + HeaderDescription( + description = viewModel.dateFilters[item.key] + ?: getStringResource(id = R.string.more_filter_all), + color = if (item.value) MoreColors.Primary else MoreColors.Secondary + ) + } } + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) - } - item { - HeaderTitle( - title = stringResource(R.string.more_filter_set_type), - modifier = Modifier.padding(top = 20.dp) - ) - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) - } + item { + HeaderTitle( + title = stringResource(R.string.more_filter_set_type), + modifier = Modifier.padding(top = 20.dp) + ) + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) + } - item { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (!viewModel.typeFilterActive.value) - IconInline( - icon = Icons.Rounded.Done, - color = MoreColors.Approved, - contentDescription = getStringResource(id = R.string.more_filter_selected) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { viewModel.clearTypeFilter() }) - .padding(4.dp) + item { + Row( + verticalAlignment = Alignment.CenterVertically, ) { - HeaderDescription( - description = stringResource(R.string.more_filter_all), - color = if (!viewModel.typeFilterActive.value) MoreColors.Primary else MoreColors.Secondary - ) + if (!viewModel.typeFilterActive.value) + IconInline( + icon = Icons.Rounded.Done, + color = MoreColors.Approved, + contentDescription = getStringResource(id = R.string.more_filter_selected) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.clearTypeFilter() }) + .padding(4.dp) + ) { + HeaderDescription( + description = stringResource(R.string.more_filter_all), + color = if (!viewModel.typeFilterActive.value) MoreColors.Primary else MoreColors.Secondary + ) + } } + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) - } - items(viewModel.currentTypeFilter.entries.sortedBy { it.key }) { item -> - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if ( - item.value - ) - IconInline( - icon = Icons.Rounded.Done, - color = MoreColors.Approved, - contentDescription = getStringResource(id = R.string.more_filter_selected) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { viewModel.toggleTypeFilter(item.key) }) - .padding(4.dp) + items(viewModel.currentTypeFilter.entries.sortedBy { it.key }) { item -> + Row( + verticalAlignment = Alignment.CenterVertically, ) { - HeaderDescription( - description = item.key, - color = if (item.value) MoreColors.Primary else MoreColors.Secondary + if ( + item.value ) + IconInline( + icon = Icons.Rounded.Done, + color = MoreColors.Approved, + contentDescription = getStringResource(id = R.string.more_filter_selected) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.toggleTypeFilter(item.key) }) + .padding(4.dp) + ) { + HeaderDescription( + description = item.key, + color = if (item.value) MoreColors.Primary else MoreColors.Secondary + ) + } } + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterViewModel.kt index 3cbc0f6df..49a145970 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterViewModel.kt @@ -24,7 +24,7 @@ import io.redlink.more.viewModels.dashboard.CoreDashboardFilterViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -class DashboardFilterViewModel(private val coreViewModel: CoreDashboardFilterViewModel) : +class DashboardFilterViewModel(val coreViewModel: CoreDashboardFilterViewModel) : ViewModel() { val currentTypeFilter = mutableStateMapOf() val currentDateFilter = mutableStateMapOf() diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoView.kt index 9d10eaa02..def58ea5e 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoView.kt @@ -41,6 +41,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.bluetooth.BLEConnectionActivity import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.extensions.showNewActivity @@ -54,185 +55,189 @@ fun InfoView(navController: NavController) { val viewModel = remember { InfoViewModel() } val studyInfo by viewModel.coreViewModel.studyModel.collectAsStateWithLifecycle() val context = LocalContext.current - LazyColumn(modifier = Modifier.fillMaxWidth()) { - item { - Divider() - InfoItem( - title = getStringResource(id = R.string.info_study_details), - imageVector = Icons.Default.Info, - contentDescription = getStringResource(id = R.string.info_study_details_desc), - onClick = { - navController.navigate(NavigationScreen.STUDY_DETAILS.routeWithParameters()) - } - ) - InfoItem( - title = getStringResource(id = R.string.info_running_observations), - imageVector = Icons.Outlined.Autorenew, - contentDescription = getStringResource(id = R.string.info_running_observations_desc), - onClick = { - navController.navigate(NavigationScreen.RUNNING_SCHEDULES.routeWithParameters()) - } - ) - InfoItem( - title = getStringResource(id = R.string.info_completed_observations), - imageVector = Icons.Default.Check, - contentDescription = getStringResource(id = R.string.info_completed_observations_desc), - onClick = { - navController.navigate(NavigationScreen.COMPLETED_SCHEDULES.routeWithParameters()) - } - ) - InfoItem( - title = NavigationScreen.BLUETOOTH_CONNECTION.stringRes(), - imageVector = Icons.Default.Watch, - contentDescription = getStringResource(id = R.string.more_ble_icon_description), - onClick = { - (context as? Activity)?.let { - showNewActivity(it, BLEConnectionActivity::class.java) + OnAppearDisappear( + { viewModel.coreViewModel.viewOpened() }, + { viewModel.coreViewModel.viewClosed() }) { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + item { + Divider() + InfoItem( + title = getStringResource(id = R.string.info_study_details), + imageVector = Icons.Default.Info, + contentDescription = getStringResource(id = R.string.info_study_details_desc), + onClick = { + navController.navigate(NavigationScreen.STUDY_DETAILS.routeWithParameters()) } - } - ) - InfoItem( - title = getStringResource(id = R.string.info_settings), - imageVector = Icons.Default.Settings, - contentDescription = getStringResource(id = R.string.info_consent_settings_desc), - onClick = { - navController.navigate(NavigationScreen.SETTINGS.routeWithParameters()) - } - ) - InfoItem( - title = getStringResource(id = R.string.info_leave_study), - imageVector = Icons.Default.ExitToApp, - contentDescription = getStringResource(id = R.string.info_leave_study_desc), - onClick = { - navController.navigate(NavigationScreen.LEAVE_STUDY.routeWithParameters()) - } - ) - } - - item { - studyInfo?.let { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - ) { + ) + InfoItem( + title = getStringResource(id = R.string.info_running_observations), + imageVector = Icons.Outlined.Autorenew, + contentDescription = getStringResource(id = R.string.info_running_observations_desc), + onClick = { + navController.navigate(NavigationScreen.RUNNING_SCHEDULES.routeWithParameters()) + } + ) + InfoItem( + title = getStringResource(id = R.string.info_completed_observations), + imageVector = Icons.Default.Check, + contentDescription = getStringResource(id = R.string.info_completed_observations_desc), + onClick = { + navController.navigate(NavigationScreen.COMPLETED_SCHEDULES.routeWithParameters()) + } + ) + InfoItem( + title = NavigationScreen.BLUETOOTH_CONNECTION.stringRes(), + imageVector = Icons.Default.Watch, + contentDescription = getStringResource(id = R.string.more_ble_icon_description), + onClick = { + (context as? Activity)?.let { + showNewActivity(it, BLEConnectionActivity::class.java) + } + } + ) + InfoItem( + title = getStringResource(id = R.string.info_settings), + imageVector = Icons.Default.Settings, + contentDescription = getStringResource(id = R.string.info_consent_settings_desc), + onClick = { + navController.navigate(NavigationScreen.SETTINGS.routeWithParameters()) + } + ) + InfoItem( + title = getStringResource(id = R.string.info_leave_study), + imageVector = Icons.Default.ExitToApp, + contentDescription = getStringResource(id = R.string.info_leave_study_desc), + onClick = { + navController.navigate(NavigationScreen.LEAVE_STUDY.routeWithParameters()) + } + ) + } + item { + studyInfo?.let { Column( - modifier = Modifier.fillMaxWidth(0.8f) + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() ) { - if (it.study.participantId != null || it.study.participantAlias != null) { - Spacer(modifier = Modifier.height(25.dp)) + + Column( + modifier = Modifier.fillMaxWidth(0.8f) + ) { + if (it.study.participantId != null || it.study.participantAlias != null) { + Spacer(modifier = Modifier.height(25.dp)) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + if (it.study.participantId != null) { + SmallTitle( + text = getStringResource(id = R.string.info_participant_credentials), + color = MoreColors.Secondary + ) + Spacer(modifier = Modifier.width(3.dp)) + SmallTitle( + text = it.study.participantId.toString(), + color = MoreColors.Secondary + ) + SmallTitle(text = ": ", color = MoreColors.Secondary) + } + if (it.study.participantAlias != null) { + BasicText( + text = it.study.participantAlias.toString(), + color = MoreColors.Secondary + ) + } + } + if (it.study.participantId != null || it.study.participantAlias != null) { + Spacer(modifier = Modifier.height(10.dp)) + Divider() + } } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() + + Column( + modifier = Modifier.fillMaxWidth(0.8f) ) { - if (it.study.participantId != null) { + Spacer(modifier = Modifier.height(10.dp)) + + if (it.study.contactPerson != null || it.study.contactEmail != null || it.study.contactPhoneNumber != null) { SmallTitle( - text = getStringResource(id = R.string.info_participant_credentials), - color = MoreColors.Secondary + text = getStringResource(id = R.string.info_contact_data), + color = MoreColors.PrimaryDark, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + textAlign = TextAlign.Center, + fontSize = 18.sp ) - Spacer(modifier = Modifier.width(3.dp)) + } + + + if (it.study.contactInstitute != null) { SmallTitle( - text = it.study.participantId.toString(), - color = MoreColors.Secondary + text = it.study.contactInstitute as String, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 5.dp), + textAlign = TextAlign.Center ) - SmallTitle(text = ": ", color = MoreColors.Secondary) } - if (it.study.participantAlias != null) { - BasicText( - text = it.study.participantAlias.toString(), - color = MoreColors.Secondary + + if (it.study.contactPerson != null) { + SmallTitle( + text = it.study.contactPerson as String, + modifier = Modifier.fillMaxWidth(), + color = MoreColors.Secondary, + textAlign = TextAlign.Center ) } - } - if (it.study.participantId != null || it.study.participantAlias != null) { - Spacer(modifier = Modifier.height(10.dp)) - Divider() - } - } - - Column( - modifier = Modifier.fillMaxWidth(0.8f) - ) { - Spacer(modifier = Modifier.height(10.dp)) - - if (it.study.contactPerson != null || it.study.contactEmail != null || it.study.contactPhoneNumber != null) { - SmallTitle( - text = getStringResource(id = R.string.info_contact_data), - color = MoreColors.PrimaryDark, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp), - textAlign = TextAlign.Center, - fontSize = 18.sp - ) - } - if (it.study.contactInstitute != null) { - SmallTitle( - text = it.study.contactInstitute as String, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 5.dp), - textAlign = TextAlign.Center - ) - } - - if (it.study.contactPerson != null) { - SmallTitle( - text = it.study.contactPerson as String, - modifier = Modifier.fillMaxWidth(), - color = MoreColors.Secondary, - textAlign = TextAlign.Center - ) - } + if (it.study.contactEmail != null) { + BasicText( + text = it.study.contactEmail as String, + fontSize = 14.sp, + modifier = Modifier.fillMaxWidth(), + color = MoreColors.Secondary, + textAlign = TextAlign.Center + ) + } + if (it.study.contactPhoneNumber != null) + BasicText( + text = it.study.contactPhoneNumber as String, + fontSize = 14.sp, + modifier = Modifier.fillMaxWidth(), + color = MoreColors.Secondary, + textAlign = TextAlign.Center + ) - if (it.study.contactEmail != null) { - BasicText( - text = it.study.contactEmail as String, - fontSize = 14.sp, - modifier = Modifier.fillMaxWidth(), - color = MoreColors.Secondary, - textAlign = TextAlign.Center - ) - } + if (it.study.contactPerson != null || it.study.contactEmail != null || it.study.contactPhoneNumber != null) { + Spacer(modifier = Modifier.height(10.dp)) + Divider() - if (it.study.contactPhoneNumber != null) - BasicText( - text = it.study.contactPhoneNumber as String, - fontSize = 14.sp, - modifier = Modifier.fillMaxWidth(), - color = MoreColors.Secondary, - textAlign = TextAlign.Center - ) + BasicText( + text = getStringResource(id = R.string.info_disclaimer), + color = MoreColors.Secondary, + textAlign = TextAlign.Center, + fontSize = 14.sp, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + ) + } - if (it.study.contactPerson != null || it.study.contactEmail != null || it.study.contactPhoneNumber != null) { - Spacer(modifier = Modifier.height(10.dp)) - Divider() - - BasicText( - text = getStringResource(id = R.string.info_disclaimer), - color = MoreColors.Secondary, - textAlign = TextAlign.Center, - fontSize = 14.sp, - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp) - ) } - } } - } - } - item { - AppVersion() + } + item { + AppVersion() + } } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoViewModel.kt index 588f8cd88..d76465c25 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoViewModel.kt @@ -12,8 +12,10 @@ package io.redlink.more.app.android.activities.info import androidx.lifecycle.ViewModel import io.redlink.more.app.android.MoreApplication +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.viewModels.studydetails.CoreStudyDetailsViewModel class InfoViewModel : ViewModel() { - val coreViewModel = CoreStudyDetailsViewModel(MoreApplication.shared!!) + val coreViewModel = + CoreStudyDetailsViewModel(MoreApplication.shared!!, NavigationRoute.INFO.viewIdentifier) } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyConfirmView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyConfirmView.kt index ec22db7f6..1b1b5f26b 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyConfirmView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyConfirmView.kt @@ -19,8 +19,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material.ButtonDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,6 +30,7 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.leaveStudy.LeaveStudyViewModel import io.redlink.more.app.android.extensions.Image import io.redlink.more.app.android.extensions.getStringResource @@ -47,94 +46,87 @@ import io.redlink.more.app.android.theme.moreImportant fun LeaveStudyConfirmView(navController: NavController) { val context = LocalContext.current val viewModel = remember { LeaveStudyViewModel() } - val backStackEntry = remember { navController.currentBackStackEntry } - val route = - backStackEntry?.arguments?.getString(NavigationScreen.LEAVE_STUDY_CONFIRM.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } - } - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { Column( - modifier = Modifier.fillMaxWidth(0.8f), - verticalArrangement = Arrangement.Center + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - viewModel.permissionModel.value?.let { - Title( - text = it.studyTitle, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - } - - Spacer(Modifier.height(80.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier.fillMaxWidth(0.8f), + verticalArrangement = Arrangement.Center ) { - Image( - id = R.drawable.warning_exclamation, - contentDescription = "More Logo", - modifier = Modifier - .fillMaxWidth(0.3f) - .aspectRatio(1.5f) - ) - } + viewModel.permissionModel.value?.let { + Title( + text = it.studyTitle, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + + Spacer(Modifier.height(80.dp)) - Spacer(Modifier.height(18.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Image( + id = R.drawable.warning_exclamation, + contentDescription = "More Logo", + modifier = Modifier + .fillMaxWidth(0.3f) + .aspectRatio(1.5f) + ) + } - BasicText( - text = stringResource(id = R.string.more_settings_withdraw_statement_long), - color = MoreColors.TextDefault, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontSize = 16.sp - ) + Spacer(Modifier.height(18.dp)) + BasicText( + text = stringResource(id = R.string.more_settings_withdraw_statement_long), + color = MoreColors.TextDefault, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 16.sp + ) - Spacer(Modifier.height(10.dp)) - SmallTitle( - text = stringResource(id = R.string.more_settings_withdraw_question_confirm), - color = MoreColors.Primary, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontSize = 16.sp - ) + Spacer(Modifier.height(10.dp)) - Spacer(Modifier.height(32.dp)) + SmallTitle( + text = stringResource(id = R.string.more_settings_withdraw_question_confirm), + color = MoreColors.Primary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 16.sp + ) - SmallTextButton( - text = stringResource(id = R.string.more_settings_continue), - buttonColors = ButtonDefaults.moreApproved(), - borderStroke = MoreColors.borderApproved() - ) { - navController.navigate(NavigationScreen.INFO.routeWithParameters()) - } + Spacer(Modifier.height(32.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { SmallTextButton( - text = getStringResource(id = R.string.more_settings_resign_confirm), - buttonColors = ButtonDefaults.moreImportant(), - borderStroke = MoreColors.borderImportant() + text = stringResource(id = R.string.more_settings_continue), + buttonColors = ButtonDefaults.moreApproved(), + borderStroke = MoreColors.borderApproved() + ) { + navController.navigate(NavigationScreen.INFO.routeWithParameters()) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() ) { - viewModel.removeParticipation(context) + SmallTextButton( + text = getStringResource(id = R.string.more_settings_resign_confirm), + buttonColors = ButtonDefaults.moreImportant(), + borderStroke = MoreColors.borderImportant() + ) { + viewModel.removeParticipation(context) + } } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyView.kt index e3af730e1..67f213f70 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyView.kt @@ -20,8 +20,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material.ButtonDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,6 +30,7 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.leaveStudy.LeaveStudyViewModel import io.redlink.more.app.android.extensions.Image import io.redlink.more.app.android.extensions.getStringResource @@ -46,85 +45,78 @@ import io.redlink.more.app.android.theme.moreImportant fun LeaveStudyView(navController: NavController) { val context = LocalContext.current val viewModel = remember { LeaveStudyViewModel() } - val backStackEntry = remember { navController.currentBackStackEntry } - val route = - backStackEntry?.arguments?.getString(NavigationScreen.LEAVE_STUDY.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } - } - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { Column( - modifier = Modifier.fillMaxWidth(0.8f), - verticalArrangement = Arrangement.Center + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - viewModel.permissionModel.value?.let { - Title( - text = it.studyTitle, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - } - Spacer(Modifier.height(80.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier.fillMaxWidth(0.8f), + verticalArrangement = Arrangement.Center ) { - Image( - id = R.drawable.warning_exclamation, - contentDescription = "More Logo", - modifier = Modifier - .fillMaxWidth(0.3f) - .aspectRatio(1.5f) - ) - } + viewModel.permissionModel.value?.let { + Title( + text = it.studyTitle, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } - Spacer(Modifier.height(18.dp)) + Spacer(Modifier.height(80.dp)) - SmallTitle( - text = stringResource(id = R.string.more_settings_withdraw_statement), - color = MoreColors.Important, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Image( + id = R.drawable.warning_exclamation, + contentDescription = "More Logo", + modifier = Modifier + .fillMaxWidth(0.3f) + .aspectRatio(1.5f) + ) + } - Spacer(Modifier.height(80.dp)) + Spacer(Modifier.height(18.dp)) - SmallTitle( - text = stringResource(id = R.string.more_settings_withdraw_question), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) + SmallTitle( + text = stringResource(id = R.string.more_settings_withdraw_statement), + color = MoreColors.Important, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) - Spacer(Modifier.height(18.dp)) + Spacer(Modifier.height(80.dp)) - SmallTextButton( - text = stringResource(id = R.string.more_settings_continue), - buttonColors = ButtonDefaults.moreApproved(), - borderStroke = MoreColors.borderApproved() - ) { - (context as? Activity)?.onBackPressed() - } + SmallTitle( + text = stringResource(id = R.string.more_settings_withdraw_question), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) - SmallTextButton( - text = getStringResource(id = R.string.more_settings_withdraw_from_study), - buttonColors = ButtonDefaults.moreImportant(), - borderStroke = MoreColors.borderImportant() - ) { - navController.navigate(NavigationScreen.LEAVE_STUDY_CONFIRM.routeWithParameters()) + Spacer(Modifier.height(18.dp)) + + SmallTextButton( + text = stringResource(id = R.string.more_settings_continue), + buttonColors = ButtonDefaults.moreApproved(), + borderStroke = MoreColors.borderApproved() + ) { + (context as? Activity)?.onBackPressed() + } + + SmallTextButton( + text = getStringResource(id = R.string.more_settings_withdraw_from_study), + buttonColors = ButtonDefaults.moreImportant(), + borderStroke = MoreColors.borderImportant() + ) { + navController.navigate(NavigationScreen.LEAVE_STUDY_CONFIRM.routeWithParameters()) + } } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyViewModel.kt index ac456877c..30760ca14 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyViewModel.kt @@ -21,27 +21,33 @@ import io.redlink.more.app.android.activities.ContentActivity import io.redlink.more.app.android.extensions.showNewActivityAndClearStack import io.redlink.more.database.entities.StudyEntity import io.redlink.more.models.PermissionModel +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.viewModels.settings.CoreSettingsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class LeaveStudyViewModel : ViewModel() { - private var coreSettingsViewModel = - CoreSettingsViewModel(MoreApplication.shared!!) + val coreViewModel = + CoreSettingsViewModel( + MoreApplication.shared!!.repositories, + MoreApplication.shared!!.sharedStorageRepository, + NavigationRoute.LEAVE_STUDY.viewIdentifier + ) val study = mutableStateOf(null) val permissionModel = mutableStateOf(null) init { - viewModelScope.launch(Dispatchers.IO) { - coreSettingsViewModel.study.collect { + coreViewModel.setExitStudyObserver(MoreApplication.shared!!) + viewModelScope.launch { + coreViewModel.study.collect { withContext(Dispatchers.Main) { study.value = it } } } - viewModelScope.launch(Dispatchers.IO) { - coreSettingsViewModel.permissionModel.collect { + viewModelScope.launch { + coreViewModel.permissionModel.collect { withContext(Dispatchers.Main) { permissionModel.value = it } @@ -49,18 +55,10 @@ class LeaveStudyViewModel : ViewModel() { } } - fun viewDidAppear() { - coreSettingsViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreSettingsViewModel.viewDidDisappear() - } - fun removeParticipation(context: Context) { WorkManager.getInstance(context).cancelAllWork() viewModelScope.launch { - coreSettingsViewModel.dataDeleted.collect { + coreViewModel.dataDeleted.collect { if (it) { (context as? Activity)?.let { activity -> withContext(Dispatchers.Main) { @@ -71,6 +69,6 @@ class LeaveStudyViewModel : ViewModel() { } } } - coreSettingsViewModel.exitStudy() + coreViewModel.exitStudy() } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginViewModel.kt index 9cf31f75f..269add380 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginViewModel.kt @@ -12,10 +12,12 @@ package io.redlink.more.app.android.activities.login import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel -import io.redlink.more.AlertController +import dev.icerock.moko.resources.desc.Raw +import dev.icerock.moko.resources.desc.StringDesc import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.stringResource -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel import io.redlink.more.models.LoginModel import io.redlink.more.registration.RegistrationService import io.redlink.more.util.validateAndNormalizeUrl @@ -45,17 +47,17 @@ class LoginViewModel( } else { AlertController.openAlertDialog( AlertDialogModel( - stringResource(R.string.more_token_error), - stringResource(R.string.more_404), - confirmLabel = "Ok" + StringDesc.Raw(stringResource(R.string.more_token_error)), + StringDesc.Raw(stringResource(R.string.more_404)), + confirmLabel = StringDesc.Raw("Ok") ) ) } } else { val dialogModel = AlertDialogModel( - title = stringResource(R.string.no_internet_connection_title), - message = stringResource(R.string.no_internet_connection_body), - confirmLabel = "Ok" + title = StringDesc.Raw(stringResource(R.string.no_internet_connection_title)), + message = StringDesc.Raw(stringResource(R.string.no_internet_connection_body)), + confirmLabel = StringDesc.Raw("Ok") ) AlertController.openAlertDialog(dialogModel) } 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 0e1a9c6e6..7d0b9508f 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 @@ -282,8 +282,7 @@ fun MainView( } ObservationDetailsView( - viewModel = obsDetailsVM, - navController = navController + viewModel = obsDetailsVM ) } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationView.kt index 10f11f61a..9bc07430a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationView.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.notification.composables.NotificationFilterViewButton import io.redlink.more.app.android.activities.notification.composables.NotificationItem import io.redlink.more.app.android.extensions.getStringResource @@ -46,48 +47,53 @@ fun NotificationView( ) { val viewModel = remember { NotificationViewModel(coreFilterViewModel) } val notificationList by viewModel.coreViewModel.notificationList.collectAsStateWithLifecycle() - LazyColumn( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxSize() - ) { + OnAppearDisappear( + { viewModel.coreViewModel.viewOpened() }, + { viewModel.coreViewModel.viewClosed() }) { - item { - Column( - modifier = Modifier - .height( - IntrinsicSize.Min - ) - ) { - NotificationFilterViewButton(navController, viewModel = viewModel) + LazyColumn( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + ) { + + item { + Column( + modifier = Modifier + .height( + IntrinsicSize.Min + ) + ) { + NotificationFilterViewButton(navController, viewModel = viewModel) + } + Spacer(modifier = Modifier.padding(10.dp)) } - Spacer(modifier = Modifier.padding(10.dp)) - } - item { - if (notificationList.isEmpty()) { - Text(text = getStringResource(id = R.string.no_notifications_yet)) + item { + if (notificationList.isEmpty()) { + Text(text = getStringResource(id = R.string.no_notifications_yet)) + } } - } - items(notificationList.sortedByDescending { it.timestamp }) { notification -> - Box( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - if (!notification.read) { - viewModel.handleNotificationAction(notification, navController) + items(notificationList.sortedByDescending { it.timestamp }) { notification -> + Box( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + if (!notification.read) { + viewModel.handleNotificationAction(notification, navController) + } } + .padding(bottom = 10.dp) + ) { + Column { + NotificationItem(viewModel, notification, navController) + MoreDivider() } - .padding(bottom = 10.dp) - ) { - Column { - NotificationItem(viewModel, notification, navController) - MoreDivider() } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterView.kt index 5e5fcfbcf..024a9048e 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterView.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.app.android.shared_composables.HeaderDescription @@ -40,42 +41,46 @@ import io.redlink.more.viewModels.notifications.CoreNotificationFilterViewModel @Composable fun NotificationFilterView(coreViewModel: CoreNotificationFilterViewModel) { val viewModel = remember { NotificationFilterViewModel(coreViewModel) } - LazyColumn { - item { - HeaderTitle( - title = stringResource(R.string.more_select_filter), - modifier = Modifier.padding(top = 20.dp) - ) - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) - } + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { + LazyColumn { + item { + HeaderTitle( + title = stringResource(R.string.more_select_filter), + modifier = Modifier.padding(top = 20.dp) + ) + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) + } - itemsIndexed(viewModel.currentFilters.entries.sortedBy { it.key.sortIndex }) { _, entry -> - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (entry.value) - IconInline( - icon = Icons.Rounded.Done, - color = MoreColors.Approved, - contentDescription = getStringResource(id = R.string.more_filter_selected) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { viewModel.toggleFilter(entry.key) }) - .padding(4.dp) + itemsIndexed(viewModel.currentFilters.entries.sortedBy { it.key.sortIndex }) { _, entry -> + Row( + verticalAlignment = Alignment.CenterVertically, ) { - HeaderDescription( - description = entry.key.type, - color = MoreColors.Secondary - ) + if (entry.value) + IconInline( + icon = Icons.Rounded.Done, + color = MoreColors.Approved, + contentDescription = getStringResource(id = R.string.more_filter_selected) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.toggleFilter(entry.key) }) + .padding(4.dp) + ) { + HeaderDescription( + description = entry.key.type, + color = MoreColors.Secondary + ) + } } + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterViewModel.kt index b44cc12a5..54bb2ccd9 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterViewModel.kt @@ -19,7 +19,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class NotificationFilterViewModel(private val coreViewModel: CoreNotificationFilterViewModel) : +class NotificationFilterViewModel(val coreViewModel: CoreNotificationFilterViewModel) : ViewModel() { val currentFilters = mutableStateMapOf() diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectActivity.kt index aae8131f1..eb6692583 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectActivity.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectActivity.kt @@ -48,7 +48,7 @@ import io.redlink.more.app.android.shared_composables.IconInline import io.redlink.more.app.android.shared_composables.MessageAlertDialog import io.redlink.more.app.android.shared_composables.MoreBackground import io.redlink.more.app.android.theme.MoreColors -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertDialogModel import io.redlink.more.viewModels.ViewManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest @@ -178,7 +178,7 @@ fun GarminConnectSSOView(viewModel: GarminConnectViewModel, webView: WebView?) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { if (webView == null || viewModel.coreViewModel.garminSSOUrl() == null) { MessageAlertDialog( - AlertDialogModel( + AlertDialogModel.fromStrings( title = "Garmin Connect", message = getStringResource(id = R.string.garmin_connect_unavailable), confirmLabel = "Ok", diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectViewModel.kt index 882391ef0..28fe691d1 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectViewModel.kt @@ -14,10 +14,10 @@ package io.redlink.more.app.android.activities.observations.garmin import android.webkit.WebResourceRequest import android.webkit.WebView import io.github.aakira.napier.Napier -import io.redlink.more.AlertController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.web.WebClientListener -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel import io.redlink.more.viewModels.garminConnectOAuth.CoreGarminConnectViewModel import kotlinx.coroutines.runBlocking @@ -57,7 +57,7 @@ class GarminConnectViewModel : WebClientListener { return } else { AlertController.openAlertDialog( - AlertDialogModel( + AlertDialogModel.fromStrings( title = "Garmin Connect", message = "Failed to authenticate with Garmin Connect. Please try again later.", confirmLabel = "Ok", diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyViewModel.kt index f8c3576db..ac8ce63f8 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyViewModel.kt @@ -15,10 +15,10 @@ import android.webkit.WebView import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import io.redlink.more.AlertController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.web.WebClientListener -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel import io.redlink.more.viewModels.limeSurvey.CoreLimeSurveyViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireView.kt index 2f65f970a..6c7fec5c1 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireView.kt @@ -15,8 +15,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -30,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.observations.questionnaire.questionType.QuestionnaireQuestionAnswer import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.ErrorMessage @@ -71,43 +70,42 @@ fun QuestionnaireView(navController: NavController, viewModel: QuestionViewModel NavigationScreen.QUESTION.routeWithParameters() ) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - - DisposableEffect(route) { - onDispose { viewModel.viewDidDisappear() } - } + OnAppearDisappear( + { viewModel.viewDidAppear() }, + { viewModel.viewDidDisappear() }) { + var selectedAnswer by rememberSaveable(route, stateSaver = AnswerValueSaver) { + mutableStateOf(null) + } - var selectedAnswer by rememberSaveable(route, stateSaver = AnswerValueSaver) { - mutableStateOf(null) - } + if (viewModel.hasData.value) { + val observation by viewModel.coreViewModel.questionModel.collectAsStateWithLifecycle( + null + ) + val type = observation?.type ?: QuestionType.NON - if (viewModel.hasData.value) { - val observation by viewModel.coreViewModel.questionModel.collectAsStateWithLifecycle(null) - val type = observation?.type ?: QuestionType.NON + Column( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + QuestionnaireQuestionAnswer( + model = viewModel, + selectedAnswer = selectedAnswer, + onAnswerSelected = { selectedAnswer = it } + ) + } - Column( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize() - ) { - QuestionnaireQuestionAnswer( - model = viewModel, + QuestionnaireButtons( + questionType = type, selectedAnswer = selectedAnswer, - onAnswerSelected = { selectedAnswer = it } + onFinish = { + viewModel.finish(type, it) + navController.navigate(NavigationScreen.QUESTIONNAIRE_RESPONSE.routeWithParameters()) + } ) + } else { + ErrorMessage(message = "${getStringResource(id = R.string.data_not_found)}!") } - - QuestionnaireButtons( - questionType = type, - selectedAnswer = selectedAnswer, - onFinish = { - viewModel.finish(type, it) - navController.navigate(NavigationScreen.QUESTIONNAIRE_RESPONSE.routeWithParameters()) - } - ) - } else { - ErrorMessage(message = "${getStringResource(id = R.string.data_not_found)}!") } + } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/runningSchedules/RunningSchedulesView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/runningSchedules/RunningSchedulesView.kt index 319b9108f..412827e7a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/runningSchedules/RunningSchedulesView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/runningSchedules/RunningSchedulesView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel import io.redlink.more.app.android.activities.dashboard.schedule.list.ScheduleListView import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel @@ -32,25 +33,29 @@ fun RunningSchedulesView( navController: NavController, taskCompletionBarViewModel: TaskCompletionBarViewModel ) { - Column( - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - ) { - ScheduleListHeader( - viewModel = viewModel, - navController = navController, - taskCompletionBarViewModel = taskCompletionBarViewModel - ) - Spacer(modifier = Modifier.height(10.dp)) - Column { - ScheduleListView( + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { + Column( + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + ScheduleListHeader( + viewModel = viewModel, navController = navController, - scheduleViewModel = viewModel, - showButton = true + taskCompletionBarViewModel = taskCompletionBarViewModel ) + Spacer(modifier = Modifier.height(10.dp)) + Column { + ScheduleListView( + navController = navController, + scheduleViewModel = viewModel, + showButton = true + ) + } } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsView.kt index c35de0d05..5473ee017 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsView.kt @@ -10,7 +10,10 @@ */ package io.redlink.more.app.android.activities.setting +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth @@ -18,48 +21,106 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Divider +import androidx.compose.material.Switch +import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.desc.Resource +import dev.icerock.moko.resources.desc.StringDesc +import io.redlink.more.SharedRes import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.Accordion import io.redlink.more.app.android.shared_composables.BasicText +import io.redlink.more.app.android.shared_composables.SmallTextButton import io.redlink.more.app.android.theme.MoreColors @Composable fun SettingsView() { + val context = LocalContext.current val model = remember { SettingsViewModel() } - Column( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth() - ) { - LazyColumn( + val needsTracking by model.coreViewModel.needsTracking.collectAsState() + val trackingApproval by model.coreViewModel.allowTracking.collectAsState() + OnAppearDisappear({ model.coreViewModel.viewOpened() }, { model.coreViewModel.viewClosed() }) { + Column( modifier = Modifier + .fillMaxHeight() .fillMaxWidth() - .weight(1f) - .padding(vertical = 16.dp) ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(vertical = 16.dp) + ) { + item { + SmallTextButton( + text = getStringResource(id = R.string.proceed_to_settings_button), + onClick = { model.coreViewModel.openSettings() } + ) + Spacer(Modifier.height(16.dp)) + } + item { + if (needsTracking) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MoreColors.TextDefault, RoundedCornerShape(8.dp)) + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + StringDesc.Resource(SharedRes.strings.app_tracking_dialog_title) + .toString(context) + ) - item { - BasicText( - text = getStringResource(id = R.string.more_settings_permission_information), - color = MoreColors.TextDefault, - ) + Switch( + checked = trackingApproval, + onCheckedChange = { isChecked -> + model.coreViewModel.setTrackingPermission(isChecked) + } + ) + } + Divider() + Text( + StringDesc.Resource(SharedRes.strings.app_tracking_dialog_message) + .toString(context) + ) + } + } - Spacer(Modifier.height(24.dp)) - } + Spacer(Modifier.height(16.dp)) + + BasicText( + text = getStringResource(id = R.string.more_settings_permission_information), + color = MoreColors.TextDefault, + ) + + Spacer(Modifier.height(24.dp)) + } - items(model.permissionModel.value?.consentInfo ?: emptyList()) { consentInfo -> - Accordion( - title = consentInfo.title, - description = consentInfo.info, - hasCheck = true, - hasSmallTitle = true, - hasPreview = false - ) + items(model.permissionModel.value?.consentInfo ?: emptyList()) { consentInfo -> + Accordion( + title = consentInfo.title, + description = consentInfo.info, + hasCheck = true, + hasSmallTitle = true, + hasPreview = false + ) + } } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsViewModel.kt index 7a8ab4bcc..fb2a962b7 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsViewModel.kt @@ -22,21 +22,25 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class SettingsViewModel : ViewModel() { - private var coreSettingsViewModel = - CoreSettingsViewModel(MoreApplication.shared!!) + val coreViewModel = + CoreSettingsViewModel( + MoreApplication.shared!!.repositories, + MoreApplication.shared!!.sharedStorageRepository + ) val study = mutableStateOf(null) val permissionModel = mutableStateOf(null) init { + coreViewModel.setExitStudyObserver(MoreApplication.shared) viewModelScope.launch(Dispatchers.IO) { - coreSettingsViewModel.study.collect { + coreViewModel.study.collect { withContext(Dispatchers.Main) { study.value = it } } } viewModelScope.launch(Dispatchers.IO) { - coreSettingsViewModel.permissionModel.collect { + coreViewModel.permissionModel.collect { withContext(Dispatchers.Main) { permissionModel.value = it } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/StudyDetailsView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/StudyDetailsView.kt index 03d70fd65..bbeeff34c 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/StudyDetailsView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/StudyDetailsView.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.studyDetails.composables.AccordionWithList import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarView import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel @@ -45,51 +46,54 @@ fun StudyDetailsView( ) { val viewModel = remember { StudyDetailsViewModel() } val studyInfo by viewModel.coreViewModel.studyModel.collectAsStateWithLifecycle() + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { + studyInfo?.let { + Column( + verticalArrangement = Arrangement.Top, + modifier = Modifier + .fillMaxSize() + ) { + LazyColumn { + item { + HeaderTitle(title = it.study.studyTitle) + Spacer(Modifier.height(12.dp)) + TaskCompletionBarView(taskCompletionBarViewModel) + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + if (it.study.start != null && it.study.end != null) { + BasicText(text = "${getStringResource(R.string.study_duration)}: ") + BasicText( + text = "${ + it.study.start!!.jvmLocalDateTime().formattedString() + } - ${ + it.study.end!!.jvmLocalDateTime().formattedString() + }", + color = MoreColors.Secondary + ) + } - studyInfo?.let { - Column( - verticalArrangement = Arrangement.Top, - modifier = Modifier - .fillMaxSize() - ) { - LazyColumn { - item { - HeaderTitle(title = it.study.studyTitle) - Spacer(Modifier.height(12.dp)) - TaskCompletionBarView(taskCompletionBarViewModel) - Spacer(Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - if (it.study.start != null && it.study.end != null) { - BasicText(text = "${getStringResource(R.string.study_duration)}: ") - BasicText( - text = "${ - it.study.start!!.jvmLocalDateTime().formattedString() - } - ${ - it.study.end!!.jvmLocalDateTime().formattedString() - }", - color = MoreColors.Secondary - ) } + Spacer(Modifier.height(40.dp)) + AccordionReadMore( + title = getStringResource(R.string.participant_information), + description = it.study.participantInfo, + modifier = Modifier + .fillMaxWidth() + ) + Spacer(Modifier.height(16.dp)) + AccordionWithList( + title = getStringResource(R.string.observation_modules), + observations = it.observations, + navController = navController + ) } - Spacer(Modifier.height(40.dp)) - AccordionReadMore( - title = getStringResource(R.string.participant_information), - description = it.study.participantInfo, - modifier = Modifier - .fillMaxWidth() - ) - Spacer(Modifier.height(16.dp)) - - AccordionWithList( - title = getStringResource(R.string.observation_modules), - observations = it.observations, - navController = navController - ) } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsView.kt index 4daf99fc2..358bae3d9 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsView.kt @@ -18,15 +18,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.navigation.NavController import io.redlink.more.app.android.R -import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.extensions.jvmLocalDate import io.redlink.more.app.android.extensions.jvmLocalDateTime @@ -39,74 +35,66 @@ import io.redlink.more.app.android.theme.MoreColors @Composable fun ObservationDetailsView( - viewModel: ObservationDetailsViewModel, - navController: NavController + viewModel: ObservationDetailsViewModel ) { - val backStackEntry = remember { navController.currentBackStackEntry } - val route = - backStackEntry?.arguments?.getString(NavigationScreen.OBSERVATION_DETAILS.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } - } - LazyColumn( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(4.dp) - ) { - item { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - ) { - HeaderTitle( - title = viewModel.observationDetailsModel.value.observationTitle, + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { + LazyColumn( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + item { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .weight(0.65f) - .padding(vertical = 11.dp) + .fillMaxWidth() + ) { + HeaderTitle( + title = viewModel.observationDetailsModel.value.observationTitle, + modifier = Modifier + .weight(0.65f) + .padding(vertical = 11.dp) + ) + } + BasicText( + text = viewModel.observationDetailsModel.value.observationType, + color = MoreColors.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 14.dp) ) - } - BasicText( - text = viewModel.observationDetailsModel.value.observationType, - color = MoreColors.Secondary, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 14.dp) - ) - TimeframeDays( - viewModel.observationDetailsModel.value.start.jvmLocalDate(), - viewModel.observationDetailsModel.value.end.jvmLocalDate(), - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) - ) - TimeframeHours( - viewModel.observationDetailsModel.value.start.jvmLocalDateTime(), - viewModel.observationDetailsModel.value.end.jvmLocalDateTime(), - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) - ) + TimeframeDays( + viewModel.observationDetailsModel.value.start.jvmLocalDate(), + viewModel.observationDetailsModel.value.end.jvmLocalDate(), + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) + TimeframeHours( + viewModel.observationDetailsModel.value.start.jvmLocalDateTime(), + viewModel.observationDetailsModel.value.end.jvmLocalDateTime(), + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Accordion( - title = getStringResource(id = R.string.participant_information), - description = viewModel.observationDetailsModel.value.participantInformation, - hasCheck = false, - hasPreview = false, - isOpen = true - ) - Spacer(modifier = Modifier.height(8.dp)) + Accordion( + title = getStringResource(id = R.string.participant_information), + description = viewModel.observationDetailsModel.value.participantInformation, + hasCheck = false, + hasPreview = false, + isOpen = true + ) + Spacer(modifier = Modifier.height(8.dp)) + } } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsViewModel.kt index 68a9606f6..56259a4c8 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsViewModel.kt @@ -24,7 +24,7 @@ class ObservationDetailsViewModel( observationId: String, ) : ViewModel() { - private val coreViewModel: CoreObservationDetailsViewModel = + val coreViewModel: CoreObservationDetailsViewModel = CoreObservationDetailsViewModel( MoreApplication.shared!!.repositories, observationId @@ -45,18 +45,5 @@ class ObservationDetailsViewModel( } } } - viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - coreViewModel.viewDidAppear() - } - } - } - - fun viewDidAppear() { - coreViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreViewModel.viewDidDisappear() } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt index 58b2aaf8d..2bded98ea 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt @@ -1,3 +1,14 @@ +/* + * 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.activities.subcomponents import android.app.Activity @@ -8,7 +19,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.work.WorkManager -import io.redlink.more.AlertController +import dev.icerock.moko.resources.desc.Raw +import dev.icerock.moko.resources.desc.StringDesc import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.ContentActivity @@ -17,11 +29,11 @@ import io.redlink.more.app.android.extensions.showNewActivityAndClearStack import io.redlink.more.app.android.shared_composables.SmallTextButton import io.redlink.more.app.android.theme.MoreColors import io.redlink.more.app.android.theme.moreImportant -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel @Composable -fun ExitButton( -) { +fun ExitButton() { val context = LocalContext.current SmallTextButton( text = getStringResource(id = R.string.more_settings_resign_confirm), @@ -32,10 +44,10 @@ fun ExitButton( ) { AlertController.openAlertDialog( AlertDialogModel( - title = context.getString(R.string.more_settings_withdraw_question_confirm), - message = context.getString(R.string.more_settings_withdraw_statement_long), - confirmLabel = context.getString(R.string.more_settings_resign_confirm), - cancelLabel = context.getString(R.string.more_settings_continue), + title = StringDesc.Raw(context.getString(R.string.more_settings_withdraw_question_confirm)), + message = StringDesc.Raw(context.getString(R.string.more_settings_withdraw_statement_long)), + confirmLabel = StringDesc.Raw(context.getString(R.string.more_settings_resign_confirm)), + cancelLabel = StringDesc.Raw(context.getString(R.string.more_settings_continue)), onConfirm = { WorkManager.getInstance(context).cancelAllWork() MoreApplication.shared!!.exitStudy { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/ObservationActionButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/ObservationActionButton.kt index 332fe2a73..fe5248f90 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/ObservationActionButton.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/ObservationActionButton.kt @@ -1,13 +1,27 @@ +/* + * 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.activities.tasks import androidx.compose.runtime.Composable import androidx.navigation.NavController +import io.github.aakira.napier.Napier import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.SmallTextButton +import io.redlink.more.logging.event import io.redlink.more.models.ScheduleState import io.redlink.more.navigation.model.NavigationRouteParameter +import io.redlink.more.observations.appUsage.model.LogEvent import io.redlink.more.observations.observationTypes.LimeSurveyType import io.redlink.more.observations.observationTypes.QuestionType @@ -20,37 +34,75 @@ fun ObservationActionButton( additionalEnableCondition: Boolean = true, onClick: () -> Unit ) { + SmallTextButton( + text = buttonText(observationType = observationType, scheduleState = scheduleState), + enabled = scheduleState.active() && buttonEnabled( + observationType = observationType, + additionalEnableCondition = additionalEnableCondition + ) + ) { + handleButtonAction( + navController = navController, + scheduleId = scheduleId, + observationType = observationType, + scheduleState = scheduleState, + onClick = onClick + ) + } +} - if (QuestionType().matches(observationType)) { - SmallTextButton( - text = getStringResource(id = R.string.more_questionnaire_start), - enabled = scheduleState.active() - ) { - navController.navigate( - NavigationScreen.QUESTION.navigationRoute(NavigationRouteParameter.SCHEDULE_ID.key to scheduleId) - ) - } - } else if (LimeSurveyType().matches(observationType)) { - SmallTextButton( - text = getStringResource(id = R.string.more_limesurvey_start), - enabled = scheduleState.active() - ) { - navController.navigate( - NavigationScreen.LIMESURVEY.navigationRoute( - NavigationRouteParameter.SCHEDULE_ID.key to scheduleId - ) +@Composable +private fun buttonText( + observationType: String, + scheduleState: ScheduleState +): String { + return when { + QuestionType().matches(observationType) -> getStringResource(id = R.string.more_questionnaire_start) + LimeSurveyType().matches(observationType) -> getStringResource(id = R.string.more_limesurvey_start) + scheduleState == ScheduleState.RUNNING -> getStringResource(id = R.string.more_observation_pause) + else -> getStringResource(id = R.string.more_observation_start) + } +} + +private fun buttonEnabled( + observationType: String, + additionalEnableCondition: Boolean +): Boolean { + val opensSeparateScreen = + QuestionType().matches(observationType) || LimeSurveyType().matches(observationType) + + return if (opensSeparateScreen) { + true + } else { + additionalEnableCondition + } +} + +private fun handleButtonAction( + navController: NavController, + scheduleId: String, + observationType: String, + scheduleState: ScheduleState, + onClick: () -> Unit +) { + val navigationScreen = when { + QuestionType().matches(observationType) -> NavigationScreen.QUESTION + LimeSurveyType().matches(observationType) -> NavigationScreen.LIMESURVEY + else -> null + } + + Napier.event( + LogEvent.BUTTON_PRESS, + "${if (scheduleState == ScheduleState.RUNNING) "Pause" else "Start"} observation $observationType" + ) + + if (navigationScreen != null) { + navController.navigate( + navigationScreen.navigationRoute( + NavigationRouteParameter.SCHEDULE_ID.key to scheduleId ) - } + ) } else { - SmallTextButton( - text = if (scheduleState == ScheduleState.RUNNING) getStringResource( - id = R.string.more_observation_pause - ) else getStringResource( - id = R.string.more_observation_start - ), - enabled = scheduleState.active() && additionalEnableCondition - ) { - onClick() - } + onClick() } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsView.kt index bdac33b93..599bcdc8a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsView.kt @@ -31,6 +31,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.observationErrors.ObservationErrorListView import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.extensions.jvmLocalDate @@ -56,7 +57,6 @@ fun TaskDetailsView( remember { TaskDetailsViewModel( MoreApplication.shared!!.dataRecorder, - MoreApplication.shared!!.observationFactory, scheduleId ) } @@ -64,123 +64,128 @@ fun TaskDetailsView( val dataPoints by viewModel.coreViewModel.dataCount.collectAsStateWithLifecycle() val taskErrors by viewModel.coreViewModel.taskObservationErrors.collectAsStateWithLifecycle() val taskErrorActions by viewModel.coreViewModel.taskObservationErrorActions.collectAsStateWithLifecycle() - Column( - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(4.dp) - ) { - taskDetails?.let { taskDetails -> - LazyColumn( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - item { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - ) { - HeaderTitle( - title = taskDetails.observationTitle, + + OnAppearDisappear( + { viewModel.coreViewModel.viewOpened() }, + { viewModel.coreViewModel.viewClosed() }) { + Column( + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + taskDetails?.let { taskDetails -> + LazyColumn( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + ) { + HeaderTitle( + title = taskDetails.observationTitle, + modifier = Modifier + .weight(0.65f) + .padding(vertical = 11.dp) + ) + if (taskDetails.state == ScheduleState.RUNNING) + SmallTextIconButton( + text = getStringResource(id = R.string.more_abort), + imageText = getStringResource(id = R.string.more_abort), + image = Icons.Rounded.Square, + imageTint = MoreColors.Important, + borderStroke = MoreColors.borderDefault(), + buttonColors = ButtonDefaults.moreSecondary2() + ) { + viewModel.stopObservation() + } + } + BasicText( + text = taskDetails.observationType, + color = MoreColors.Secondary, modifier = Modifier - .weight(0.65f) - .padding(vertical = 11.dp) + .fillMaxWidth() + .padding(bottom = 14.dp) ) - if (taskDetails.state == ScheduleState.RUNNING) - SmallTextIconButton( - text = getStringResource(id = R.string.more_abort), - imageText = getStringResource(id = R.string.more_abort), - image = Icons.Rounded.Square, - imageTint = MoreColors.Important, - borderStroke = MoreColors.borderDefault(), - buttonColors = ButtonDefaults.moreSecondary2() - ) { - viewModel.stopObservation() - } - } - BasicText( - text = taskDetails.observationType, - color = MoreColors.Secondary, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 14.dp) - ) - TimeframeDays( - taskDetails.start.jvmLocalDate(), - taskDetails.end.jvmLocalDate(), - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) - ) - TimeframeHours( - taskDetails.start.jvmLocalDateTime(), - taskDetails.end.jvmLocalDateTime(), - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) - ) + TimeframeDays( + taskDetails.start.jvmLocalDate(), + taskDetails.end.jvmLocalDate(), + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) + TimeframeHours( + taskDetails.start.jvmLocalDateTime(), + taskDetails.end.jvmLocalDateTime(), + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Accordion( - title = getStringResource(id = R.string.participant_information), - description = taskDetails.participantInformation, - hasCheck = false, - hasPreview = false - ) - Spacer(modifier = Modifier.height(8.dp)) + Accordion( + title = getStringResource(id = R.string.participant_information), + description = taskDetails.participantInformation, + hasCheck = false, + hasPreview = false + ) + Spacer(modifier = Modifier.height(8.dp)) + } } - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - scheduleId?.let { - if (!taskDetails.state.completed()) { - DatapointCollectionView( - dataPoints, - taskDetails.state - ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + scheduleId?.let { + if (!taskDetails.state.completed()) { + DatapointCollectionView( + dataPoints, + taskDetails.state + ) + } } } - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Bottom, - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.CenterHorizontally + ) { - ObservationErrorListView( - errors = taskErrors, - errorActions = taskErrorActions - ) + ObservationErrorListView( + errors = taskErrors, + errorActions = taskErrorActions + ) - if (!taskDetails.hidden) { - ObservationActionButton( - navController, - taskDetails.scheduleId, - taskDetails.observationType, - taskDetails.state, - if (taskDetails.observationType == PolarVerityHeartRateType( - emptySet() - ).observationType - ) viewModel.polarHrReady.value else true - ) { - if (taskDetails.state == ScheduleState.RUNNING) { - viewModel.pauseObservation() - } else { - viewModel.startObservation() + if (!taskDetails.hidden) { + ObservationActionButton( + navController, + taskDetails.scheduleId, + taskDetails.observationType, + taskDetails.state, + if (taskDetails.observationType == PolarVerityHeartRateType( + emptySet() + ).observationType + ) viewModel.polarHrReady.value else true + ) { + if (taskDetails.state == ScheduleState.RUNNING) { + viewModel.pauseObservation() + } else { + viewModel.startObservation() + } } } } } - } + } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt index 0d555765a..6a71798fc 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt @@ -15,7 +15,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.redlink.more.app.android.MoreApplication import io.redlink.more.observations.DataRecorder -import io.redlink.more.observations.ObservationFactory import io.redlink.more.services.bluetooth.polar.PolarStates import io.redlink.more.viewModels.tasks.CoreTaskDetailsViewModel import kotlinx.coroutines.Dispatchers @@ -24,7 +23,6 @@ import kotlinx.coroutines.withContext class TaskDetailsViewModel( dataRecorder: DataRecorder, - observationFactory: ObservationFactory, scheduleId: String ) : ViewModel() { val coreViewModel: CoreTaskDetailsViewModel = diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt index 06667fd41..56c52257a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt @@ -15,7 +15,10 @@ import android.graphics.Bitmap import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient +import io.github.aakira.napier.Napier import io.github.aakira.napier.log +import io.redlink.more.logging.event +import io.redlink.more.observations.appUsage.model.LogEvent class WebClient : WebViewClient() { private var clientListener: WebClientListener? = null @@ -42,7 +45,7 @@ class WebClient : WebViewClient() { override fun onPageCommitVisible(view: WebView?, url: String?) { super.onPageCommitVisible(view, url) - log { "WebViewClient\$onPageCommitVisible: $url" } + Napier.event(LogEvent.URL_OPEN, "WebView: $url") } override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationDataManager.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationDataManager.kt index f3d2fe574..8acc75ded 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationDataManager.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationDataManager.kt @@ -49,7 +49,11 @@ class AndroidObservationDataManager(context: Context, repository: MainRepository if (!immediately && workManager != null) { onCompletion(tryWorkManagerThenFallback()) } else { - Napier.w { "WorkManager not available, falling back to direct upload..." } + if (workManager == null) { + Napier.w { "WorkManager not available, falling back to direct upload..." } + } else { + Napier.i { "Immediate upload requested, bypassing WorkManager for direct upload..." } + } onCompletion(directUploadFallback()) } } 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 9af5138d1..ba1b77d95 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 @@ -19,28 +19,43 @@ import io.redlink.more.app.android.observations.accelerometer.AccelerometerObser 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.scopes.AppDispatchers +import io.redlink.more.scopes.MoreScope import io.redlink.more.scopes.Scope +import io.redlink.more.services.store.SharedStorageRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class AndroidObservationFactory( context: Context, observationDataManager: ObservationDataManager, - repository: MainRepository + repository: MainRepository, + sharedStorageRepository: SharedStorageRepository, + scope: MoreScope = Scope ) : - ObservationFactory(repository, observationDataManager) { + ObservationFactory( + repository, + sharedStorageRepository, + observationDataManager + ) { init { - observations.addAll( - setOf( - AccelerometerObservation(context, repository), - GPSObservation(context, repository, gpsService = GPSService(context)), - PolarHeartRateObservation(repository) - ) - ) + registerObservation { + AccelerometerObservation(context, repository) + } + registerObservation { + GPSObservation(context, repository, gpsService = GPSService(context)) + } + registerObservation { + PolarHeartRateObservation(repository) + } + registerObservation { + appUsageObservation!! + } - Scope.launch(Dispatchers.IO) { + scope.launch(AppDispatchers.io) { GPSStateListener.gpsEnabled.collect { withContext(Dispatchers.Main) { super.updateObservationErrors() @@ -48,7 +63,7 @@ class AndroidObservationFactory( } } - Scope.launch(Dispatchers.IO) { + scope.launch(AppDispatchers.io) { BluetoothStateListener.bluetoothEnabled.collect { withContext(Dispatchers.Main) { super.updateObservationErrors() @@ -56,7 +71,7 @@ class AndroidObservationFactory( } } - Scope.launch(Dispatchers.IO) { + scope.launch(AppDispatchers.io) { super.studyObservationTypes.collect { studyObservationTypes -> val permissions = super.observations.filter { it.observationType.observationType in studyObservationTypes } @@ -82,4 +97,11 @@ class AndroidObservationFactory( } } + override fun observationPostConstruct(observation: Observation) { + observation.setPermissionObserver( + AndroidObservationPermissionObserver( + permissionRepository + ) + ) + } } \ No newline at end of file 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 new file mode 100644 index 000000000..639b3ca3b --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationPermissionObserver.kt @@ -0,0 +1,80 @@ +/* + * 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.app.Activity +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.SharedRes +import io.redlink.more.app.android.MoreApplication +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.ObservationPermissionObserver +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.observationTypes.AppUsageObservationType +import io.redlink.more.observations.observationTypes.ObservationType +import io.redlink.more.services.store.PermissionApprovalState +import io.redlink.more.services.store.PermissionRepository +import io.redlink.more.services.store.PermissionType + +class AndroidObservationPermissionObserver( + private val permissionRepository: PermissionRepository, + private val context: Context = MoreApplication.appContext!! +) : ObservationPermissionObserver { + + override fun permissionState(observationType: ObservationType): PermissionApprovalState { + if (observationType.observationType == AppUsageObservationType().observationType) { + return permissionRepository.getPermission(PermissionType.APP_TRACKING) + } + + return if (PermissionUtils.hasAllPermissions(observationType.sensorPermissions, context)) { + PermissionApprovalState.GRANTED + } else { + PermissionApprovalState.DECLINED + } + } + + override fun requestPermission(observationType: ObservationType) { + if (observationType.observationType == AppUsageObservationType().observationType) { + if (permissionState(observationType) != PermissionApprovalState.NOT_SET) { + return + } + AlertController.openAlertDialog( + AlertDialogModel( + title = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_title), + message = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_message), + confirmLabel = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_positive_button), + cancelLabel = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_negative_button), + onConfirm = { + Napier.event(LogEvent.APP_TRACKING_ACCEPTED) + }, + onDecline = { + Napier.event(LogEvent.APP_TRACKING_DECLINED) + } + ) + ) + return + } + + val activity = (context as? Activity) ?: ActivityProvider.getCurrentActivity() + if (activity != null) { + MoreApplication.shared!!.observationFactory.observation(observationType.observationType) + ?.let { observation -> + PermissionUtils.requestPermissions(observation, activity) + } + } + } +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSObservation.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSObservation.kt index c795619d9..5fc642125 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSObservation.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSObservation.kt @@ -19,12 +19,12 @@ import android.util.Log import androidx.core.app.ActivityCompat import com.google.android.gms.location.LocationResult import io.github.aakira.napier.Napier -import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.observations.showPermissionAlertDialog 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.observationTypes.GPSType +import io.redlink.more.services.store.PermissionApprovalState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -57,13 +57,15 @@ class GPSObservation( override fun start(): Boolean { Napier.d { "Trying to start GPS..." } - if (this.hasPermission()) { + if (this.hasPermission() == PermissionApprovalState.GRANTED) { val listener = this scope.launch { Napier.d { "Registering GPS Service..." } gpsService.registerForLocationUpdates(listener) } return true + } else { + this.requestPermission() } return false } @@ -81,9 +83,12 @@ class GPSObservation( if (!GPSStateListener.gpsEnabled.value) { errors.add("location_disabled") } - if (!hasPermission()) { + if (this.hasPermission() != PermissionApprovalState.GRANTED) { errors.add("location_permission_not_granted") - showPermissionAlertDialog() + if (!isPermissionRequested(observationType.observationType)) { + markPermissionRequested(observationType.observationType) + showPermissionAlertDialog() + } } return errors } @@ -114,8 +119,8 @@ class GPSObservation( Napier.d { "Location available: $available" } } - private fun hasPermission(): Boolean { - return this.hasPermissions(MoreApplication.appContext!!) + private fun checkPermission(): Boolean { + return this.hasPermission() == PermissionApprovalState.GRANTED } private fun hasPermissions(context: Context): Boolean { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt index 22c7b77cf..38eaf3d7e 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt @@ -149,7 +149,10 @@ class PolarHeartRateObservation(repos: MainRepository) : val errors = mutableSetOf() if (!hasPermissions(MoreApplication.appContext!!)) { errors.add("error_access_bluetooth") - showPermissionAlertDialog() + if (!isPermissionRequested(observationType.observationType)) { + markPermissionRequested(observationType.observationType) + showPermissionAlertDialog() + } PolarStates.hrFeatureReady(false) } if (!BluetoothStateListener.bluetoothEnabled.value) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationExtension.kt index d1d39ee18..f91ea1bee 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationExtension.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationExtension.kt @@ -11,21 +11,23 @@ package io.redlink.more.app.android.observations -import io.redlink.more.AlertController +import dev.icerock.moko.resources.desc.Raw +import dev.icerock.moko.resources.desc.StringDesc import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.stringResource -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel import io.redlink.more.observations.Observation import io.redlink.more.observations.observationTypes.ObservationType fun Observation.showPermissionAlertDialog() { AlertController.openAlertDialog( AlertDialogModel( - title = stringResource(R.string.required_permissions_not_granted_title), - message = stringResource(R.string.required_permission_not_granted_message), - confirmLabel = stringResource(R.string.proceed_to_settings_button), - cancelLabel = stringResource(R.string.proceed_without_granting_button), + title = StringDesc.Raw(stringResource(R.string.required_permissions_not_granted_title)), + message = StringDesc.Raw(stringResource(R.string.required_permission_not_granted_message)), + confirmLabel = StringDesc.Raw(stringResource(R.string.proceed_to_settings_button)), + cancelLabel = StringDesc.Raw(stringResource(R.string.proceed_without_granting_button)), onConfirm = { MoreApplication.openSettings.value = true } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt index a0c6e5b0e..6de20b8a6 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt @@ -31,12 +31,15 @@ import io.redlink.more.app.android.observations.showPermissionAlertDialog import io.redlink.more.app.android.util.ActivityProvider import io.redlink.more.observations.ObservationFactory import io.redlink.more.observations.ObservationManager +import io.redlink.more.scopes.AppDispatchers import io.redlink.more.scopes.Scope import io.redlink.more.viewModels.ViewManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class ObservationRecordingService : Service() { private var observationManager: ObservationManager? = null @@ -537,44 +540,54 @@ class ObservationRecordingService : Service() { scheduleIds: Set, activity: Activity ) { - val observations = - MoreApplication.shared?.observationFactory?.observations - ?: emptySet() + Scope.launch { + val observationsTypes = + MoreApplication.shared!!.repositories.schedule.observationTypesForScheduleIds( + scheduleIds + ).firstOrNull() ?: emptySet() + val observations = + MoreApplication.shared!!.observationFactory.observations.filter { it.observationType.observationType in observationsTypes } + if (observations.isEmpty()) { + startWithoutPermissionCheck(scheduleIds) + return@launch + } - if (observations.isEmpty()) { - startWithoutPermissionCheck(scheduleIds) - return - } + withContext(AppDispatchers.main) { + val allPermissionsGranted = observations.all { observation -> + PermissionUtils.hasAllPermissions(observation, activity) + } - val allPermissionsGranted = observations.all { observation -> - PermissionUtils.hasAllPermissions(observation, activity) - } + if (allPermissionsGranted) { + startWithoutPermissionCheck(scheduleIds) + } else { + val observationNeedingPermissions = + observations.firstOrNull { observation -> + !PermissionUtils.hasAllPermissions(observation, activity) + } - if (allPermissionsGranted) { - startWithoutPermissionCheck(scheduleIds) - } else { - val observationNeedingPermissions = observations.firstOrNull { observation -> - !PermissionUtils.hasAllPermissions(observation, activity) - } + if (observationNeedingPermissions != null) { + pendingScheduleIds.addAll(scheduleIds) - if (observationNeedingPermissions != null) { - pendingScheduleIds.addAll(scheduleIds) - PermissionUtils.requestPermissions( - observationNeedingPermissions, - activity - ) { granted -> - if (granted) { - checkPermissionsAndStart(scheduleIds, activity) + PermissionUtils.requestPermissions( + observationNeedingPermissions, + activity + ) { granted -> + if (granted) { + checkPermissionsAndStart(scheduleIds, activity) + } else { + observationNeedingPermissions.showPermissionAlertDialog() + pendingScheduleIds.removeAll(scheduleIds) + } + } } else { - observationNeedingPermissions.showPermissionAlertDialog() - pendingScheduleIds.removeAll(scheduleIds) + startWithoutPermissionCheck(scheduleIds) } } - } else { - startWithoutPermissionCheck(scheduleIds) } + } + } /** diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MessageAlertDialog.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MessageAlertDialog.kt index 6eed7d5bf..f7f3fd56e 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MessageAlertDialog.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MessageAlertDialog.kt @@ -18,17 +18,19 @@ import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import io.redlink.more.app.android.theme.MoreColors -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertDialogModel @Composable fun MessageAlertDialog(model: AlertDialogModel) { + val context = LocalContext.current MessageAlertDialog( - title = model.title, - message = model.message, - positiveButtonTitle = model.confirmLabel, - negativeButtonTitle = model.cancelLabel, + title = model.title.toString(context), + message = model.message.toString(context), + positiveButtonTitle = model.confirmLabel.toString(context), + negativeButtonTitle = model.cancelLabel?.toString(context), onPositive = model.onConfirm, onNegative = model.onDecline ) diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MoreBackgroundComposable.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MoreBackgroundComposable.kt index 684391361..36e4df934 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MoreBackgroundComposable.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MoreBackgroundComposable.kt @@ -39,11 +39,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.redlink.more.AlertController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.main.MainTabView import io.redlink.more.app.android.theme.MoreColors import io.redlink.more.app.android.theme.MorePlatformTheme +import io.redlink.more.dialog.AlertController +import io.redlink.more.viewModels.ViewManager @Composable fun MoreBackground( @@ -61,12 +62,15 @@ fun MoreBackground( ) { val context = LocalContext.current val alertDialogModel by AlertController.alertDialogModel.collectAsStateWithLifecycle(null) - if (MoreApplication.openSettings.value) { + val showSettingsView by ViewManager.showSettingsView.collectAsStateWithLifecycle(false) + + if (MoreApplication.openSettings.value || showSettingsView) { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = Uri.fromParts("package", context.packageName, null) } context.startActivity(intent) MoreApplication.openSettings.value = false + ViewManager.showSettingsView(false) } MorePlatformTheme { Scaffold( diff --git a/gradle.properties b/gradle.properties index d5fa0bda3..4d6892454 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,13 @@ +# +# 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/). +# #Gradle org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" #Kotlin @@ -7,4 +17,5 @@ android.useAndroidX=true android.nonTransitiveRClass=true #MPP kotlin.mpp.enableCInteropCommonization=true -kotlin.mpp.androidSourceSetLayoutVersion=2 \ No newline at end of file +kotlin.mpp.androidSourceSetLayoutVersion=2 +moko.resources.disableStaticFrameworkWarning=true \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index c5084e0bc..c379dd790 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -112,10 +112,12 @@ 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 */; }; 1FA763E82A42F834007C1CF9 /* NotificationFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA763E62A42F834007C1CF9 /* NotificationFilterViewModel.swift */; }; 1FA763E92A42F834007C1CF9 /* NotificationFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA763E72A42F834007C1CF9 /* NotificationFilterView.swift */; }; 1FA9ADD12F5F0993004DF4ED /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1FA9ADD02F5F0993004DF4ED /* GoogleService-Info.plist */; }; 1FB338B72E853936006BA594 /* StudyLoadingErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB338B62E853936006BA594 /* StudyLoadingErrorView.swift */; }; + 1FBCF72B2F699971002ABE61 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1FBCF72A2F699971002ABE61 /* InfoPlist.xcstrings */; }; 1FBD513A2BBC3E2D0029D185 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 1FBD51392BBC3E2D0029D185 /* FirebaseMessaging */; }; 1FBD513C2BBC3E8A0029D185 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 1FBD513B2BBC3E8A0029D185 /* FirebaseCrashlytics */; }; 1FC4F87429D2B86100F65026 /* DataUploadBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC4F87329D2B86100F65026 /* DataUploadBackgroundTask.swift */; }; @@ -328,10 +330,12 @@ 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 = ""; }; 1FA763E62A42F834007C1CF9 /* NotificationFilterViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationFilterViewModel.swift; sourceTree = ""; }; 1FA763E72A42F834007C1CF9 /* NotificationFilterView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationFilterView.swift; sourceTree = ""; }; 1FA9ADD02F5F0993004DF4ED /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 1FB338B62E853936006BA594 /* StudyLoadingErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyLoadingErrorView.swift; sourceTree = ""; }; + 1FBCF72A2F699971002ABE61 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; 1FC4F87329D2B86100F65026 /* DataUploadBackgroundTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataUploadBackgroundTask.swift; sourceTree = ""; }; 1FC9574B2C072B7900EB92D6 /* More-Notification-Service-Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "More-Notification-Service-Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 1FC9574D2C072B7900EB92D6 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; @@ -506,6 +510,7 @@ 1F34796F29B8AFCB0030CA15 /* Observations */ = { isa = PBXGroup; children = ( + 1FA044452F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift */, 1F34797029B8AFE10030CA15 /* IOSObservationFactory.swift */, 1F34797429B8BECB0030CA15 /* AccelerometerObservation.swift */, 1F80EC4629C83F74004667B1 /* iOSObservationDataManager.swift */, @@ -834,6 +839,7 @@ 1F0026DA29CC8E730034EF65 /* AppDelegate.swift */, 058557D7273AAEEB004C7B11 /* Preview Content */, 1F6C31522A13EB7F00EED533 /* Launch Screen.storyboard */, + 1FBCF72A2F699971002ABE61 /* InfoPlist.xcstrings */, ); path = iosApp; sourceTree = ""; @@ -1088,6 +1094,7 @@ 1F9DB1A5298D02FB00DBB7DB /* MoreColors.xcassets in Resources */, 1FA9ADD12F5F0993004DF4ED /* GoogleService-Info.plist in Resources */, 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, + 1FBCF72B2F699971002ABE61 /* InfoPlist.xcstrings in Resources */, 1F9DB1A3298D022E00DBB7DB /* MoreImages.xcassets in Resources */, 1FF6D8AD2BC516270050AF10 /* PrivacyInfo.xcprivacy in Resources */, 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */, @@ -1260,6 +1267,7 @@ 1FDC264A29C1CFE80011D8A4 /* NavigationText.swift in Sources */, 1F9C3E90298AACC100B9AC82 /* MoreMainBackgroundView.swift in Sources */, 07190FEC29E834E800A8CB1F /* LeaveStudyConfirmationView.swift in Sources */, + 1FA044462F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift in Sources */, 1F6C31512A121EA500EED533 /* WebViewViewModel.swift in Sources */, 7555FF83242A565900829871 /* ContentView.swift in Sources */, 1F8847E0299160400023EF10 /* MoreTextFieldStyle.swift in Sources */, diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index eaf127095..426663523 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -32,14 +32,15 @@ class AppDelegate: NSObject, UIApplicationDelegate { static let dataUploadManager = DataUploadManager() static let shared: Shared = { let dataManager = iOSObservationDataManager(repository: repositories, scope: Scope.shared, studyScope: StudyScope.shared, dispatchers: AppDispatchers.shared) + let userDefaults = UserDefaultsRepository() return Shared( localNotificationListener: LocalPushNotifications(), repositories: repositories, - sharedStorageRepository: UserDefaultsRepository(), + sharedStorageRepository: userDefaults, observationDataManager: dataManager, mainBluetoothConnector: polarConnector, - observationFactory: IOSObservationFactory(repository: repositories, dataManager: dataManager), + observationFactory: IOSObservationFactory(repository: repositories, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: 30, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() @@ -53,6 +54,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { NapierProxyKt.napierDebugBuild(antilog: nil) #endif + EventCollection.shared.addObserver(observer: EventConsumer()) + FirebaseApp.configure() FirebaseConfiguration.shared.setLoggerLevel(.debug) fcmService.register() @@ -120,3 +123,10 @@ extension AppDelegate: MessagingDelegate { userInfo: tokenDict) } } + +class EventConsumer: EventObserver { + func onEvent(event: LogEvent, message: String?) { + // Handle events, e.g., send to Analytics + print("Received Event: \(event.key) - \(message ?? "")") + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index ca1439d27..6d46849c9 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -1,104 +1,120 @@ - - + - - BGTaskSchedulerPermittedIdentifiers - - $(PRODUCT_BUNDLE_IDENTIFIER).data-upload - $(PRODUCT_BUNDLE_IDENTIFIER).dailyRefresh - $(PRODUCT_BUNDLE_IDENTIFIER).observation-reminder-refresh - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0.0 - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLIconFile - more_logo_hf2 - CFBundleURLName - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleURLSchemes - - umm-app - - - - CFBundleVersion - 1.0.0 - FirebaseAppDelegateProxyEnabled - - ITSAppUsesNonExemptEncryption - - LSRequiresIPhoneOS - - NSBluetoothAlwaysUsageDescription - $(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health devices - NSBluetoothPeripheralUsageDescription - $(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health devices - NSCameraUsageDescription - $(PRODUCT_NAME) needs your camera to scan QR-Code - NSLocationAlwaysAndWhenInUseUsageDescription - $(PRODUCT_NAME) needs to access you location to track your position for certain studies - NSLocationAlwaysUsageDescription - $(PRODUCT_NAME) always needs access to you location to track your position for certain studies - NSLocationUsageDescription - $(PRODUCT_NAME) needs to access you location to track your position for certain studies - NSLocationWhenInUseUsageDescription - $(PRODUCT_NAME) needs access to your location to track your position for certain studies - NSMotionUsageDescription - $(PRODUCT_NAME) needs access to your motion data to record your movement data in the background - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - - UIBackgroundModes - - bluetooth-central - fetch - location - processing - remote-notification - - UILaunchStoryboardName - Launch Screen.storyboard - UIRequiredDeviceCapabilities - - armv7 - accelerometer - gyroscope - gps - bluetooth-le - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - CFBundleLocalizations - - en - de - - + + BGTaskSchedulerPermittedIdentifiers + + $(PRODUCT_BUNDLE_IDENTIFIER).data-upload + $(PRODUCT_BUNDLE_IDENTIFIER).dailyRefresh + $(PRODUCT_BUNDLE_IDENTIFIER).observation-reminder-refresh + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0.0 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLIconFile + more_logo_hf2 + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleURLSchemes + + umm-app + + + + CFBundleVersion + 1.0.0 + FirebaseAppDelegateProxyEnabled + + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + NSBluetoothAlwaysUsageDescription + $(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health + devices + + NSBluetoothPeripheralUsageDescription + $(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health + devices + + NSCameraUsageDescription + $(PRODUCT_NAME) needs your camera to scan QR-Code + NSLocationAlwaysAndWhenInUseUsageDescription + $(PRODUCT_NAME) needs to access you location to track your position for certain + studies + + NSLocationAlwaysUsageDescription + $(PRODUCT_NAME) always needs access to you location to track your position for + certain studies + + NSLocationUsageDescription + $(PRODUCT_NAME) needs to access you location to track your position for certain + studies + + NSLocationWhenInUseUsageDescription + $(PRODUCT_NAME) needs access to your location to track your position for certain + studies + + NSMotionUsageDescription + $(PRODUCT_NAME) needs access to your motion data to record your movement data in the + background + + NSUserTrackingUsageDescription + $(PRODUCT_NAME) wants to track your app usage for your study + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + bluetooth-central + fetch + location + processing + remote-notification + + UILaunchStoryboardName + Launch Screen.storyboard + UIRequiredDeviceCapabilities + + armv7 + accelerometer + gyroscope + gps + bluetooth-le + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + CFBundleLocalizations + + en + de + + diff --git a/iosApp/iosApp/InfoPlist.xcstrings b/iosApp/iosApp/InfoPlist.xcstrings new file mode 100644 index 000000000..fd63a8a9e --- /dev/null +++ b/iosApp/iosApp/InfoPlist.xcstrings @@ -0,0 +1,171 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "More" + } + } + } + }, + "NSBluetoothAlwaysUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) 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" + } + } + } + }, + "NSBluetoothPeripheralUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) 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" + } + } + } + }, + "NSCameraUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) 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" + } + } + } + }, + "NSLocationAlwaysAndWhenInUseUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) benötigt Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) needs to access you location to track your position for certain studies" + } + } + } + }, + "NSLocationAlwaysUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) 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" + } + } + } + }, + "NSLocationUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) benötigt Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) needs to access you location to track your position for certain studies" + } + } + } + }, + "NSLocationWhenInUseUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "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." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) needs access to your location to track your position for certain studies" + } + } + } + }, + "NSMotionUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) 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" + } + } + } + }, + "NSUserTrackingUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) 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" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/iosApp/iosApp/Observations/GPSObservation.swift b/iosApp/iosApp/Observations/GPSObservation.swift index d15db0bae..462d222c3 100644 --- a/iosApp/iosApp/Observations/GPSObservation.swift +++ b/iosApp/iosApp/Observations/GPSObservation.swift @@ -15,25 +15,40 @@ import CoreLocation import Foundation -import shared import UIKit +import shared class GPSObservation: Observation_ { private let manager: CLLocationManager = CLLocationManager() + private let locationDelegateProxy: LocationDelegateProxy public var currentLocation = CLLocation() - private var running = false init(repos: MainRepository, sensorPermissions: Set) { + self.locationDelegateProxy = LocationDelegateProxy(owner: nil) super.init(repos: repos, observationType: GPSType(sensorPermissions: sensorPermissions)) - manager.delegate = self + + self.locationDelegateProxy.owner = self + + if Thread.isMainThread { + manager.delegate = locationDelegateProxy + } else { + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + self.manager.delegate = self.locationDelegateProxy + } + } manager.desiredAccuracy = kCLLocationAccuracyBest } override func start() -> Bool { - if observerAccessible() { - manager.allowsBackgroundLocationUpdates = true - manager.showsBackgroundLocationIndicator = true - manager.startUpdatingLocation() + if hasPermission() == .granted { + DispatchQueue.main.async { + self.manager.allowsBackgroundLocationUpdates = true + self.manager.showsBackgroundLocationIndicator = true + self.manager.startUpdatingLocation() + } return true } return false @@ -41,7 +56,6 @@ class GPSObservation: Observation_ { override func stop(onCompletion: @escaping () -> Void) { manager.stopUpdatingLocation() - running = false onCompletion() } @@ -51,7 +65,8 @@ class GPSObservation: Observation_ { errors.insert("Permission request pending until observation is about to start") manager.requestWhenInUseAuthorization() } else if manager.authorizationStatus != .authorizedWhenInUse - && manager.authorizationStatus != .authorizedAlways { + && manager.authorizationStatus != .authorizedAlways + { errors.insert("Permission not granted to access location of the device") PermissionManager.openSensorPermissionDialog() } else if !CLLocationManager.locationServicesEnabled() { @@ -60,21 +75,46 @@ class GPSObservation: Observation_ { return errors } - override func applyObservationConfig(settings: Dictionary) {} + override func applyObservationConfig(settings: [String: Any]) { + } } -extension GPSObservation: CLLocationManagerDelegate { - func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { +extension GPSObservation { + fileprivate func handleLocations(_ locations: [CLLocation]) { let data = locations.compactMap { location in - let dict = ["longitude": location.coordinate.longitude, "latitude": location.coordinate.latitude, "altitude": location.altitude] + let dict = [ + "longitude": location.coordinate.longitude, + "latitude": location.coordinate.latitude, + "altitude": location.altitude, + ] return ObservationBulkModel(data: dict, timestamp: Int64(location.timestamp.timeIntervalSince1970)) } storeData(data: data) {} } - func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + fileprivate func handleAuthorizationChange(_ manager: CLLocationManager) { if manager.authorizationStatus == .restricted || manager.authorizationStatus == .denied || manager.accuracyAuthorization != .fullAccuracy { super.stopAndSetState(state: .active, scheduleId: nil) } } } + +private final class LocationDelegateProxy: NSObject, CLLocationManagerDelegate { + weak var owner: GPSObservation? + + init(owner: GPSObservation?) { + self.owner = owner + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + owner?.handleLocations(locations) + } + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + owner?.handleAuthorizationChange(manager) + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print("Location error:", error) + } +} diff --git a/iosApp/iosApp/Observations/IOSObservationFactory.swift b/iosApp/iosApp/Observations/IOSObservationFactory.swift index a41c822b5..948ae53c5 100644 --- a/iosApp/iosApp/Observations/IOSObservationFactory.swift +++ b/iosApp/iosApp/Observations/IOSObservationFactory.swift @@ -17,10 +17,22 @@ import Foundation import shared class IOSObservationFactory: ObservationFactory { - override init(repository: MainRepository, dataManager: ObservationDataManager) { - super.init(repository: repository, dataManager: dataManager) - observations.add(GPSObservation(repos: repository, sensorPermissions: ["gpsAlways"])) - observations.add(AccelerometerBackgroundObservation(repos: repository, sensorPermissions: ["cmsensorrecorder"])) - observations.add(PolarVerityHeartRateObservation(repos: repository, sensorPermissions: ["bluetoothAlways"])) + init(repository: MainRepository, dataManager: ObservationDataManager, userDefaults: SharedStorageRepository) { + super.init(repository: repository, sharedStorageRepository: userDefaults, dataManager: dataManager, scope: Scope.shared) + registerObservation { + GPSObservation(repos: repository, sensorPermissions: ["gpsAlways"]) + } + + registerObservation { + AccelerometerBackgroundObservation(repos: repository, sensorPermissions: ["cmsensorrecorder"]) + } + + registerObservation { + PolarVerityHeartRateObservation(repos: repository, sensorPermissions: ["bluetoothAlways"]) + } + } + + override func observationPostConstruct(observation: Observation_) { + observation.setPermissionObserver(observer: IOSObservationPermissionObserver()) } } diff --git a/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift b/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift new file mode 100644 index 000000000..902f942b0 --- /dev/null +++ b/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift @@ -0,0 +1,137 @@ +// +// IOSObservationPermissionObserver.swift +// iosApp +// +// Created by Junie on 10.03.26. +// + +import AppTrackingTransparency +import CoreBluetooth +import CoreLocation +import CoreMotion +import Foundation +import shared + +class IOSObservationPermissionObserver: NSObject, ObservationPermissionObserver { + private let locationManager = CLLocationManager() + private var centralManager: CBCentralManager? + private var motionActivityManager: CMMotionActivityManager? + + func permissionState(observationType: ObservationType) -> PermissionApprovalState { + if observationType is GPSType { + let lm = CLLocationManager() + let status = lm.authorizationStatus + return if status == .notDetermined { + .notSet + } else if (status == .authorizedAlways || status == .authorizedWhenInUse) && lm.accuracyAuthorization == .fullAccuracy { + .granted + } else { + .declined + } + } + if observationType is AccelerometerType { + let status = CMSensorRecorder.authorizationStatus() + return if status == .notDetermined { + .notSet + } else if status == .authorized { + .granted + } else { + .declined + } + } + if observationType is PolarVerityHeartRateType { + let status = CBManager.authorization + return if status == .notDetermined { + .notSet + } else if status == .allowedAlways { + .granted + } else { + .declined + } + } + if observationType is AppUsageObservationType { + let status = ATTrackingManager.trackingAuthorizationStatus + if status == .notDetermined { + return .notSet + } else if status == .authorized { + Napier.event(.appTrackingAccepted) + return .granted + } else { + Napier.event(.appTrackingDeclined) + return .declined + } + } + return .granted + } + + func requestPermission(observationType: ObservationType) { + if observationType is GPSType { + locationManager.delegate = self + let status = locationManager.authorizationStatus + if status == .notDetermined { + // Request both When In Use and Always (if available in Info.plist) + locationManager.requestWhenInUseAuthorization() + locationManager.requestAlwaysAuthorization() + } else if status == .denied || status == .restricted || locationManager.accuracyAuthorization != .fullAccuracy { + PermissionManager.openSensorPermissionDialog() + } + return + } + + if observationType is AccelerometerType { + let status = CMSensorRecorder.authorizationStatus() + if status == .notDetermined { + // Trigger the motion permission prompt by starting activity updates briefly + let mam = CMMotionActivityManager() + motionActivityManager = mam + mam.startActivityUpdates(to: OperationQueue.main) { [weak self] _ in + self?.motionActivityManager?.stopActivityUpdates() + self?.motionActivityManager = nil + } + } else if status == .denied || status == .restricted { + PermissionManager.openSensorPermissionDialog() + } + return + } + + if observationType is PolarVerityHeartRateType { + let status = CBManager.authorization + if status == .notDetermined { + // Creating a CBCentralManager triggers the Bluetooth permission prompt + centralManager = CBCentralManager(delegate: self, queue: nil) + } else if status == .denied || status == .restricted { + PermissionManager.openSensorPermissionDialog() + } + return + } + + if observationType is AppUsageObservationType { + let status = ATTrackingManager.trackingAuthorizationStatus + if status == .notDetermined { + ATTrackingManager.requestTrackingAuthorization { _ in + // Observation flow will re-check via permissionState + } + } else if status == .denied || status == .restricted { + PermissionManager.openSensorPermissionDialog() + } + return + } + + // Default fallback for other observation types + PermissionManager.openSensorPermissionDialog() + } +} + +extension IOSObservationPermissionObserver: CLLocationManagerDelegate { + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + // No-op: Observation flow will re-check via permissionState + locationManager.delegate = nil + } +} + +extension IOSObservationPermissionObserver: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + // Release once we have a state update; prompt has been shown (if needed) + centralManager = nil + } +} diff --git a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings index 24f0e0fee..e99f7dd57 100644 --- a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -276,10 +276,6 @@ } } }, - "Button to start a limesurvey" : { - "comment" : "A button label that can be translated.", - "isCommentAutoGenerated" : true - }, "camera_to_scan_qr_code" : { "extractionState" : "manual", "localizations" : { diff --git a/iosApp/iosApp/Services/PermissionManager.swift b/iosApp/iosApp/Services/PermissionManager.swift index e5f99c669..8224b984a 100644 --- a/iosApp/iosApp/Services/PermissionManager.swift +++ b/iosApp/iosApp/Services/PermissionManager.swift @@ -14,13 +14,14 @@ // import AVFoundation +import AppTrackingTransparency import CoreBluetooth import CoreLocation import CoreMotion import Foundation -import shared import UIKit import UserNotifications +import shared enum PermissionStatus { case accepted, declined, requesting, non @@ -82,16 +83,17 @@ class PermissionManager: NSObject, ObservableObject { if notificationStatus == .accepted { AppDelegate.registerForNotifications() } else { - AlertController.shared.openAlertDialog(model: AlertDialogModel( - title: "Notification Permissions Not Granted", - message: "We request permission to send you push notifications. This assists in maintaining the study's current status at all times and serves as a reminder for your tasks.", - confirmLabel: "Proceed to Settings", - cancelLabel: "Proceed Without Granting Permissions", - onConfirm: { - if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - })) + AlertController.shared.openAlertDialog( + model: AlertDialogModel.companion.fromStrings( + title: "Notification Permissions Not Granted", + message: "We request permission to send you push notifications. This assists in maintaining the study's current status at all times and serves as a reminder for your tasks.", + confirmLabel: "Proceed to Settings", + cancelLabel: "Proceed Without Granting Permissions", + onConfirm: { + if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + })) } requestPermission() } @@ -106,6 +108,14 @@ class PermissionManager: NSObject, ObservableObject { } } + private var appTrackingStatus: PermissionStatus = .non { + didSet { + if appTrackingStatus.userResponded() { + requestPermission() + } + } + } + private var bluetoothStatus: PermissionStatus = .non { didSet { if bluetoothStatus.userResponded() { @@ -118,7 +128,6 @@ class PermissionManager: NSObject, ObservableObject { override init() { super.init() - setPermissionValues(observationPermissions: AppDelegate.shared.observationFactory.studySensorPermissions()) } private func requestGpsAuthorization(always: Bool = true) { @@ -127,7 +136,8 @@ class PermissionManager: NSObject, ObservableObject { locationManager.requestAlwaysAuthorization() gpsStatus = .requesting } else if locationManager.authorizationStatus == CLAuthorizationStatus.denied - || locationManager.authorizationStatus == CLAuthorizationStatus.restricted || locationManager.accuracyAuthorization != .fullAccuracy { + || locationManager.authorizationStatus == CLAuthorizationStatus.restricted || locationManager.accuracyAuthorization != .fullAccuracy + { gpsStatus = .declined } else { gpsStatus = .accepted @@ -214,11 +224,36 @@ class PermissionManager: NSObject, ObservableObject { } private func requestPermissionCamera() { - AVCaptureDevice.requestAccess(for: .video, completionHandler: { accessGranted in - DispatchQueue.main.async { - self.cameraPermissionGranted = accessGranted + AVCaptureDevice.requestAccess( + for: .video, + completionHandler: { accessGranted in + DispatchQueue.main.async { + self.cameraPermissionGranted = accessGranted + } + }) + } + + func requestAppTrackingAuthorization() { + let status = ATTrackingManager.trackingAuthorizationStatus + if status == .notDetermined { + ATTrackingManager.requestTrackingAuthorization { [weak self] newStatus in + Task { @MainActor in + if newStatus == .authorized { + Napier.event(.appTrackingAccepted) + self?.appTrackingStatus = .accepted + } else { + Napier.event(.appTrackingDeclined) + self?.appTrackingStatus = .declined + } + } } - }) + } else if status == .authorized { + Napier.event(.appTrackingAccepted) + appTrackingStatus = .accepted + } else { + Napier.event(.appTrackingDeclined) + appTrackingStatus = .declined + } } func anyNeededPermissionDeclined() -> Bool { @@ -230,12 +265,14 @@ class PermissionManager: NSObject, ObservableObject { cameraStatus = observationPermissions.contains("camera") ? .requesting : .non cmSensorStatus = observationPermissions.contains("cmsensorrecorder") ? .requesting : .non bluetoothStatus = observationPermissions.contains("bluetoothAlways") ? .requesting : .non + appTrackingStatus = observationPermissions.contains("appTracking") ? .requesting : .non } func requestPermission(permissionRequest: Bool = false) { print("Requesting Permissions") if permissionRequest { permissionsRequested = true + setPermissionValues(observationPermissions: AppDelegate.shared.observationFactory.studySensorPermissions()) } if permissionsRequested, let observer { if notificationStatus == .requesting && !notificationStatus.userResponded() { @@ -246,6 +283,8 @@ class PermissionManager: NSObject, ObservableObject { bluetoothStatus = checkBluetoothAuthorization() } else if cmSensorStatus == .requesting && !cmSensorStatus.userResponded() { requestCMSensorRecorder() + } else if appTrackingStatus == .requesting && !appTrackingStatus.userResponded() { + requestAppTrackingAuthorization() } else { print("Continuing") observer.accepted() @@ -259,6 +298,7 @@ class PermissionManager: NSObject, ObservableObject { bluetoothStatus = bluetoothStatus.resetStatus() cmSensorStatus = cmSensorStatus.resetStatus() cameraStatus = cameraStatus.resetStatus() + appTrackingStatus = appTrackingStatus.resetStatus() permissionsRequested = false } @@ -268,6 +308,7 @@ class PermissionManager: NSObject, ObservableObject { bluetoothStatus = .non cmSensorStatus = .non cameraStatus = .non + appTrackingStatus = .non permissionsRequested = false } } @@ -283,22 +324,25 @@ extension PermissionManager: CLLocationManagerDelegate { private static var permissionAlertOpenedThisSession = false + static func resetPermissionAlertFlag() { + permissionAlertOpenedThisSession = false + } + static func openSensorPermissionDialog() { if permissionAlertOpenedThisSession { return } permissionAlertOpenedThisSession = true - AlertController.shared.openAlertDialog(model: AlertDialogModel( - title: "Required Permissions Were Not Granted", - message: "This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?", - confirmLabel: "Proceed to Settings", - cancelLabel: "Proceed Without Granting Permissions", - onConfirm: { - if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - }, onDecline: nil)) + AlertController.shared.openAlertDialog( + model: AlertDialogModel.companion.fromStrings( + title: "Required Permissions Were Not Granted", message: "This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?", confirmLabel: "Proceed to Settings", cancelLabel: "Proceed Without Granting Permissions", + onConfirm: { + if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + }, onDecline: nil)) + } } diff --git a/iosApp/iosApp/Utils/Napier.swift b/iosApp/iosApp/Utils/Napier.swift index af8f6be4d..cae37a7f7 100644 --- a/iosApp/iosApp/Utils/Napier.swift +++ b/iosApp/iosApp/Utils/Napier.swift @@ -10,35 +10,47 @@ import Foundation import shared enum Napier { - static func i(_ message: String, - file: String = #fileID, - function: String = #function, - line: Int = #line) { + static func i( + _ message: String, + file: String = #fileID, + function: String = #function, + line: Int = #line + ) { let tag = "\(file)#\(function):\(line)" KMMLogger.shared.i(tag: tag, message: message) } - static func d(_ message: String, - file: String = #fileID, - function: String = #function, - line: Int = #line) { + static func d( + _ message: String, + file: String = #fileID, + function: String = #function, + line: Int = #line + ) { let tag = "\(file)#\(function):\(line)" KMMLogger.shared.d(tag: tag, message: message) } - static func w(_ message: String, - file: String = #fileID, - function: String = #function, - line: Int = #line) { + static func w( + _ message: String, + file: String = #fileID, + function: String = #function, + line: Int = #line + ) { let tag = "\(file)#\(function):\(line)" KMMLogger.shared.w(tag: tag, message: message) } - static func e(_ message: String, - file: String = #fileID, - function: String = #function, - line: Int = #line) { + static func e( + _ message: String, + file: String = #fileID, + function: String = #function, + line: Int = #line + ) { let tag = "\(file)#\(function):\(line)" KMMLogger.shared.e(tag: tag, message: message) } + + static func event(_ event: LogEvent, message: String? = nil) { + KMMLogger.shared.event(event: event, message: message) + } } diff --git a/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift b/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift index bafd157f7..a0a9776c1 100644 --- a/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift +++ b/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift @@ -18,7 +18,6 @@ import SwiftUI struct CompletedSchedules: View { @StateObject var scheduleViewModel: ScheduleViewModel - private let navigationStrings = "Navigation" @State var tasksCompleted: Double = 0 @State var totalTasks: Double = 0 var body: some View { @@ -27,5 +26,11 @@ struct CompletedSchedules: View { ScheduleView(viewModel: scheduleViewModel) } .customNavigationTitle(with: NavigationScreen.pastObservations.localize(), displayMode: .inline) + .onAppear { + scheduleViewModel.coreModel.viewDidAppear() + } + .onDisappear { + scheduleViewModel.coreModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Components/ExitButton.swift b/iosApp/iosApp/Views/Components/ExitButton.swift index f470072bb..f80d4aa29 100644 --- a/iosApp/iosApp/Views/Components/ExitButton.swift +++ b/iosApp/iosApp/Views/Components/ExitButton.swift @@ -20,7 +20,7 @@ struct ExitButton: View { } private func withdraw() { - AlertController.shared.openAlertDialog(model: AlertDialogModel( + AlertController.shared.openAlertDialog(model: AlertDialogModel.companion.fromStrings( title: "sure_message", message: "leave_confirmation_message", confirmLabel: "withdraw", diff --git a/iosApp/iosApp/Views/Components/MoreAlertDialog.swift b/iosApp/iosApp/Views/Components/MoreAlertDialog.swift index 6c4fd772e..29e446e93 100644 --- a/iosApp/iosApp/Views/Components/MoreAlertDialog.swift +++ b/iosApp/iosApp/Views/Components/MoreAlertDialog.swift @@ -6,8 +6,8 @@ // Copyright © 2024 Redlink GmbH. All rights reserved. // -import shared import SwiftUI +import shared struct MoreAlertDialog: View { let alertDialogModel: AlertDialogModel @@ -17,7 +17,7 @@ struct MoreAlertDialog: View { Color.black.opacity(0.5) .ignoresSafeArea(edges: .all) VStack(spacing: 20) { - Text(LocalizedStringKey(alertDialogModel.title)) + Text(alertDialogModel.title.localized()) .foregroundColor(.more.primary) .font(.headline) .multilineTextAlignment(.center) @@ -27,7 +27,7 @@ struct MoreAlertDialog: View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading) { - Text(LocalizedStringKey(alertDialogModel.message)) + Text(alertDialogModel.message.localized()) .foregroundColor(.more.primary) .font(.subheadline) .multilineTextAlignment(.leading) @@ -43,7 +43,7 @@ struct MoreAlertDialog: View { onConfirm() } } label: { - Text(LocalizedStringKey(alertDialogModel.confirmLabel)) + Text(alertDialogModel.confirmLabel.localized()) } if let cancelLabel = alertDialogModel.cancelLabel { @@ -53,10 +53,10 @@ struct MoreAlertDialog: View { } } label: { if #available(iOS 17.0, *) { - Text(LocalizedStringKey(cancelLabel)) + Text(cancelLabel.localized()) .foregroundStyle(Color.more.primary) } else { - Text(LocalizedStringKey(cancelLabel)) + Text(cancelLabel.localized()) .foregroundColor(.more.primary) } } @@ -76,15 +76,16 @@ struct MoreAlertDialog: View { } #Preview { - MoreAlertDialog(alertDialogModel: AlertDialogModel( - title: "Needed permissions were not given", - message: "This study needs one or more sensor permission to correctly work. You may decline sensor permissions, but if you do, the app and the study may not work fully or as expected. Would you like to go to the settings and allow the app to access needed sensor permissions?", - confirmLabel: "Required Permissions Were Not Granted", - cancelLabel: "Continue without allowing", - onConfirm: { - print("Settings") - }, - onDecline: { - print("Continue") - })) + MoreAlertDialog( + alertDialogModel: AlertDialogModel.companion.fromStrings( + title: "Needed permissions were not given", + message: "This study needs one or more sensor permission to correctly work. You may decline sensor permissions, but if you do, the app and the study may not work fully or as expected. Would you like to go to the settings and allow the app to access needed sensor permissions?", + confirmLabel: "Required Permissions Were Not Granted", + cancelLabel: "Continue without allowing", + onConfirm: { + print("Settings") + }, + onDecline: { + print("Continue") + })) } diff --git a/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift b/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift index 0b7f92c1f..c35ec1f2f 100644 --- a/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift +++ b/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift @@ -50,6 +50,7 @@ extension WebViewViewModel: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { print("WebView didFinish") + Napier.event(.urlOpen, message: "WebView: \(webView.url?.absoluteString ?? "")") } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { diff --git a/iosApp/iosApp/Views/Consent/ConsentView.swift b/iosApp/iosApp/Views/Consent/ConsentView.swift index b5a56d7d8..30c89ab5c 100644 --- a/iosApp/iosApp/Views/Consent/ConsentView.swift +++ b/iosApp/iosApp/Views/Consent/ConsentView.swift @@ -45,6 +45,7 @@ struct ConsentView: View { .tint(.more.primary) } else { MoreActionButton(disabled: .constant(viewModel.requestedPermissions || registration.isLoading), alertOpen: $viewModel.showErrorAlert) { + Napier.event(.buttonPress, message: "Consent accepted") viewModel.requestPermissions() } label: { VStack { @@ -59,6 +60,7 @@ struct ConsentView: View { } Spacer() MoreActionButton(backgroundColor: .more.important, disabled: .constant(false)) { + Napier.event(.buttonPress, message: "Consent declined") viewModel.decline() } label: { Text("decline_button") @@ -82,13 +84,14 @@ struct ConsentView: View { let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) let repos = MainRepositoryImpl(appDatabase: database) let dataManager = iOSObservationDataManager(repository: repos, scope: Scope.shared, studyScope: StudyScope.shared, dispatchers: AppDispatchers.shared) + let userDefaults = UserDefaultsRepository() let shared = Shared( localNotificationListener: LocalPushNotifications(), repositories: repos, - sharedStorageRepository: UserDefaultsRepository(), + sharedStorageRepository: userDefaults, observationDataManager: dataManager, mainBluetoothConnector: IOSBluetoothConnector(), - observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager), + observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() ) diff --git a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift index a443209bd..ccd723cda 100644 --- a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift +++ b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift @@ -16,8 +16,8 @@ import AVFoundation import Combine import KMPNativeCoroutinesCombine -import shared import UIKit +import shared class ConsentViewModel: ObservableObject { private let coreModel: CoreConsentViewModel @@ -39,20 +39,22 @@ class ConsentViewModel: ObservableObject { coreModel = CoreConsentViewModel(registrationService: registrationService, studyConsentTitle: String(localized: "study_consent")) createPublisher(for: coreModel.permissions) - .receive(on: DispatchQueue.main) - .sink { _ in - } receiveValue: { [weak self] model in - self?.permissionModel = model - } - .store(in: &cancellables) + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] model in + self?.permissionModel = model + } + .store(in: &cancellables) } func onAppear() { permissionManager.observer = self + coreModel.viewDidAppear() } func onDisappear() { permissionManager.observer = nil + coreModel.viewDidDisappear() } func resetPermissionRequest() { @@ -80,22 +82,23 @@ extension ConsentViewModel: PermissionManagerObserver { func accepted() { Task { @MainActor in if permissionManager.anyNeededPermissionDeclined() { - AlertController.shared.openAlertDialog(model: - AlertDialogModel( - title: "Required Permissions Were Not Granted", - message: "This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?", - confirmLabel: "Proceed to Settings", - cancelLabel: "Proceed Without Granting Permissions", - onConfirm: { - if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - self.resetPermissionRequest() - }, - onDecline: { - self.acceptConsent() - self.requestedPermissions = false - })) + AlertController.shared.openAlertDialog( + model: + AlertDialogModel.companion.fromStrings( + title: "Required Permissions Were Not Granted", + message: "This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?", + confirmLabel: "Proceed to Settings", + cancelLabel: "Proceed Without Granting Permissions", + onConfirm: { + if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + self.resetPermissionRequest() + }, + onDecline: { + self.acceptConsent() + self.requestedPermissions = false + })) } else { self.acceptConsent() self.requestedPermissions = false diff --git a/iosApp/iosApp/Views/Dashboard/DashboardView.swift b/iosApp/iosApp/Views/Dashboard/DashboardView.swift index de91ddb84..84f678e08 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardView.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardView.swift @@ -19,11 +19,9 @@ import SwiftUI struct DashboardView: View { @EnvironmentObject private var navigationModalState: NavigationModalState @StateObject var viewModel: ScheduleViewModel - private let stringTable = "DashboardView" @State var totalTasks: Double = 0 @State var selection: Int = 0 @State var tasksCompleted: Double = 0 - private let navigationStrings = "Navigation" var body: some View { VStack { ScheduleListHeader(scheduleViewModel: viewModel, totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) @@ -34,6 +32,12 @@ struct DashboardView: View { } } .customNavigationTitle(with: NavigationScreen.dashboard.localize(), displayMode: .inline) + .onAppear { + viewModel.coreModel.viewDidAppear() + } + .onDisappear { + viewModel.coreModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift b/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift index 807128759..a9b6050c8 100644 --- a/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift +++ b/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift @@ -33,8 +33,12 @@ struct GarminConnectView: View { } } .onAppear { + viewModel.coreViewModel.viewDidAppear() viewModel.clearAllWebViewData() } + .onDisappear() { + viewModel.coreViewModel.viewDidDisappear() + } .onReceive(viewModel.$shouldClose.removeDuplicates()) { close in if close { dismiss() diff --git a/iosApp/iosApp/Views/GarminConnect/GarminConnectViewModel.swift b/iosApp/iosApp/Views/GarminConnect/GarminConnectViewModel.swift index f9221fbd5..3ef90142d 100644 --- a/iosApp/iosApp/Views/GarminConnect/GarminConnectViewModel.swift +++ b/iosApp/iosApp/Views/GarminConnect/GarminConnectViewModel.swift @@ -74,7 +74,7 @@ class GarminConnectViewModel: ObservableObject { } if failure { await MainActor.run { - let dialog = AlertDialogModel(title: "Error during Callback", message: "Error accessing your Garmin Connect Account! Please try again later!", confirmLabel: "Ok", cancelLabel: nil, onConfirm: { [weak self] in + let dialog = AlertDialogModel.companion.fromStrings(title: "Error during Callback", message: "Error accessing your Garmin Connect Account! Please try again later!", confirmLabel: "Ok", cancelLabel: nil, onConfirm: { [weak self] in self?.coreViewModel.closeView() self?.shouldClose = true }) diff --git a/iosApp/iosApp/Views/Info/InfoView.swift b/iosApp/iosApp/Views/Info/InfoView.swift index d4977a089..d58844612 100644 --- a/iosApp/iosApp/Views/Info/InfoView.swift +++ b/iosApp/iosApp/Views/Info/InfoView.swift @@ -16,11 +16,9 @@ import SwiftUI struct InfoView: View { - @StateObject var viewModel: InfoViewModel - private let navigationStrings = "Navigation" - private let infoStrings = "Info" - + let viewModel: InfoViewModel @EnvironmentObject private var navigationModalState: NavigationModalState + var body: some View { ScrollView { Divider() @@ -58,6 +56,12 @@ struct InfoView: View { } .padding(.horizontal, 10) .customNavigationTitle(with: NavigationScreen.info.localize()) + .onAppear { + viewModel.studyCoreModel.viewDidAppear() + } + .onDisappear { + viewModel.studyCoreModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Info/InfoViewModel.swift b/iosApp/iosApp/Views/Info/InfoViewModel.swift index 6d9f9730b..a0a125872 100644 --- a/iosApp/iosApp/Views/Info/InfoViewModel.swift +++ b/iosApp/iosApp/Views/Info/InfoViewModel.swift @@ -18,7 +18,7 @@ import KMPNativeCoroutinesCombine import shared class InfoViewModel: ObservableObject { - private let studyCoreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared) + let studyCoreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared, customViewIdentifier: NavigationRoute.info.viewIdentifier) @Published var studyTitle: String? @Published var contactInstitute: String? @Published var contactPerson: String? diff --git a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift index bd7f9b554..35e824a72 100644 --- a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift +++ b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift @@ -14,9 +14,10 @@ // import SwiftUI +import shared struct LeaveStudyView: View { - @StateObject private var viewModel: SettingsViewModel = SettingsViewModel() + private let viewModel: SettingsViewModel = SettingsViewModel(viewIdentifier: NavigationRoute.leaveStudy.viewIdentifier) @EnvironmentObject var contentViewModel: ContentViewModel @EnvironmentObject private var navigationModalState: NavigationModalState @@ -75,5 +76,11 @@ struct LeaveStudyView: View { LeaveStudyConfirmationView(viewModel: viewModel) } .customNavigationTitle(with: NavigationScreen.withdrawStudy.localize()) + .onAppear { + viewModel.coreViewModel.viewDidAppear() + } + .onDisappear { + viewModel.coreViewModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index e31074d19..13134d4f8 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -99,7 +99,7 @@ struct LoginView: View { model.validate() } else { AlertController.shared.openAlertDialog( - model: AlertDialogModel( + model: AlertDialogModel.companion.fromStrings( title: "no_internet_title", message: "no_internet_message", confirmLabel: "Ok", @@ -143,13 +143,14 @@ struct LoginView: View { let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) let repos = MainRepositoryImpl(appDatabase: database) let dataManager = iOSObservationDataManager(repository: repos, scope: Scope.shared, studyScope: StudyScope.shared, dispatchers: AppDispatchers.shared) + let userDefaults = UserDefaultsRepository() let shared = Shared( localNotificationListener: LocalPushNotifications(), repositories: repos, - sharedStorageRepository: UserDefaultsRepository(), + sharedStorageRepository: userDefaults, observationDataManager: dataManager, mainBluetoothConnector: IOSBluetoothConnector(), - observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager), + observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() ) diff --git a/iosApp/iosApp/Views/Login/LoginViewModel.swift b/iosApp/iosApp/Views/Login/LoginViewModel.swift index 09945795d..792a1a528 100644 --- a/iosApp/iosApp/Views/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Views/Login/LoginViewModel.swift @@ -57,7 +57,7 @@ class LoginViewModel: ObservableObject { if loginModel.valid() { registrationService.sendRegistrationToken(loginModel: loginModel) } else { - AlertController.shared.openAlertDialog(model: AlertDialogModel(title: "Token or Endpoint invalid", message: "login_model_invalid_body", confirmLabel: "Ok", cancelLabel: nil, onConfirm: nil, onDecline: nil)) + AlertController.shared.openAlertDialog(model: AlertDialogModel.companion.fromStrings(title: "Token or Endpoint invalid", message: "login_model_invalid_body", confirmLabel: "Ok", cancelLabel: nil, onConfirm: nil, onDecline: nil)) } } diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index 84027b0e7..6c567d399 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -116,13 +116,14 @@ struct ScanQRCodeView: View { studyScope: StudyScope.shared, dispatchers: AppDispatchers.shared ) + let userDefaults = UserDefaultsRepository() let sharedContainer = Shared( localNotificationListener: LocalPushNotifications(), repositories: repos, - sharedStorageRepository: UserDefaultsRepository(), + sharedStorageRepository: userDefaults, observationDataManager: dataManager, mainBluetoothConnector: IOSBluetoothConnector(), - observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager), + observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() diff --git a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift index bbb6e377d..77939407b 100644 --- a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift +++ b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift @@ -17,11 +17,11 @@ import shared import SwiftUI struct NotificationFilterView: View { - @StateObject private var viewModel: NotificationFilterViewModel + private let viewModel: NotificationFilterViewModel @State var filtersChanged = false init(coreVM: CoreNotificationFilterViewModel) { - _viewModel = StateObject(wrappedValue: NotificationFilterViewModel(coreViewModel: coreVM)) + viewModel = NotificationFilterViewModel(coreViewModel: coreVM) } var body: some View { @@ -52,5 +52,11 @@ struct NotificationFilterView: View { } .customNavigationTitle(with: NavigationScreen.notificationFilter.localize()) .navigationBarTitleDisplayMode(.inline) + .onAppear { + viewModel.coreViewModel.viewDidAppear() + } + .onDisappear { + viewModel.coreViewModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift index 4829e9f8b..d849cd285 100644 --- a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift +++ b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift @@ -18,7 +18,6 @@ import KMPNativeCoroutinesCombine import shared class NotificationFilterViewModel: ObservableObject { - private let stringTable = "NotificationFilter" let coreViewModel: CoreNotificationFilterViewModel @Published var allFilters: [NotificationFilterTypeModel: Bool] = [:] diff --git a/iosApp/iosApp/Views/Notification/NotificationView.swift b/iosApp/iosApp/Views/Notification/NotificationView.swift index 552477204..4820dffbc 100644 --- a/iosApp/iosApp/Views/Notification/NotificationView.swift +++ b/iosApp/iosApp/Views/Notification/NotificationView.swift @@ -58,5 +58,12 @@ struct NotificationView: View { } .frame(maxWidth: .infinity) .customNavigationTitle(with: NavigationScreen.notifications.localize()) + .onAppear { + notificationViewModel.coreModel.viewDidAppear() + } + .onDisappear { + notificationViewModel.coreModel.viewDidDisappear() + } } } + diff --git a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift index cbffd31e9..8ff724855 100644 --- a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift +++ b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift @@ -19,7 +19,7 @@ import shared class NotificationViewModel: ObservableObject { private let filterViewModel: CoreNotificationFilterViewModel - private let coreModel: CoreNotificationViewModel + let coreModel: CoreNotificationViewModel @Published var notificationList: [NotificationModel] = [] diff --git a/iosApp/iosApp/Views/Registration/RegistrationObservable.swift b/iosApp/iosApp/Views/Registration/RegistrationObservable.swift index 35ecbe898..c822745ea 100644 --- a/iosApp/iosApp/Views/Registration/RegistrationObservable.swift +++ b/iosApp/iosApp/Views/Registration/RegistrationObservable.swift @@ -46,10 +46,10 @@ class RegistrationObservable: ObservableObject { .removeDuplicates() .receive(on: DispatchQueue.main) .sink { _ in - } receiveValue: { [weak self] error in - self?.error = error - if error != nil && self?.study != nil { - AlertController.shared.openAlertDialog(model: AlertDialogModel( + } receiveValue: { [weak self] networkError in + self?.error = networkError + if networkError != nil && self?.study != nil { + AlertController.shared.openAlertDialog(model: AlertDialogModel.companion.fromStrings( title: "consent_error_title", message: "consent_error_body", confirmLabel: "Ok", diff --git a/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift b/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift index 74cf32d05..9356c826c 100644 --- a/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift +++ b/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift @@ -20,12 +20,17 @@ struct RunningSchedules: View { @StateObject var scheduleViewModel: ScheduleViewModel @State var totalTasks: Double = 0 @State var tasksCompleted: Double = 0 - private let navigationStrings = "Navigation" var body: some View { VStack { ScheduleListHeader(scheduleViewModel: scheduleViewModel, totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) ScheduleView(viewModel: scheduleViewModel) } .customNavigationTitle(with: NavigationScreen.runningObservations.localize(), displayMode: .inline) + .onAppear { + scheduleViewModel.coreModel.viewDidAppear() + } + .onDisappear { + scheduleViewModel.coreModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift b/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift index 7465d0f13..b81f9f7ab 100644 --- a/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift +++ b/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift @@ -23,52 +23,41 @@ struct ObservationButton: View { var observationType: String var state: ScheduleState var disabled: Bool - private let stringTable = "ScheduleListView" var body: some View { VStack { - if QuestionType_().matches(type: observationType) { - MoreActionButton( - disabled: .constant(disabled), - action: { - navigationModalState.openView(screen: .questionObservation, scheduleId: scheduleId) - } - ) { - VStack { + MoreActionButton(disabled: .constant(disabled), action: buttonAction) { + VStack { + if QuestionType_().matches(type: observationType) { Text("start_questionnaire") + } else if LimeSurveyType().matches(type: observationType) { + Text("Start LimeSurvey") + } else if state == ScheduleState.running { + Text("pause_observation") + } else { + Text("start_observation") } } - } else if observationType == "lime-survey-observation" { - MoreActionButton( - disabled: .constant(disabled), - action: { - navigationModalState.openView(screen: .limeSurvey, scheduleId: scheduleId) - } - ) { - VStack { - Text("Button to start a limesurvey") - } - } + } + } + } + + private func buttonAction() { + var screenToOpen: NavigationScreen? = + if QuestionType_().matches(type: observationType) { + .questionObservation + } else if LimeSurveyType().matches(type: observationType) { + .limeSurvey } else { - MoreActionButton( - disabled: .constant(disabled), - action: { - if state == .running { - observationActionDelegate.pause(scheduleId: scheduleId) - } else { - observationActionDelegate.start(scheduleId: scheduleId) - } - } - ) { - VStack { - if state == ScheduleState.running { - Text("pause_observation") - } else { - Text("start_observation") - } - } - } + nil } + Napier.event(.buttonPress, message: "\(state == .running ? "Pause" : "Start") observation \(observationType)") + if let screenToOpen { + navigationModalState.openView(screen: screenToOpen, scheduleId: scheduleId) + } else if state == .running { + observationActionDelegate.pause(scheduleId: scheduleId) + } else { + observationActionDelegate.start(scheduleId: scheduleId) } } } diff --git a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift index d01900fd8..4b135f230 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift @@ -20,7 +20,7 @@ import shared class ScheduleViewModel: ObservableObject { let recorder = AppDelegate.shared.dataRecorder let scheduleListType: ScheduleListType - private let coreModel: CoreScheduleViewModel + let coreModel: CoreScheduleViewModel let filterViewModel: DashboardFilterViewModel = DashboardFilterViewModel() diff --git a/iosApp/iosApp/Views/Settings/SettingsView.swift b/iosApp/iosApp/Views/Settings/SettingsView.swift index de642dee5..f5960d066 100644 --- a/iosApp/iosApp/Views/Settings/SettingsView.swift +++ b/iosApp/iosApp/Views/Settings/SettingsView.swift @@ -13,18 +13,57 @@ // https://commonsclause.com/). // -import shared import SwiftUI +import shared struct SettingsView: View { @StateObject private var viewModel: SettingsViewModel = SettingsViewModel() @State private var exitButton = Color.more.important - private let stringTable = "SettingsView" - private let navigationStrings = "Navigation" - var body: some View { VStack(alignment: .leading) { + MoreActionButton( + disabled: .constant(false), + action: { + viewModel.coreViewModel.openSettings() + } + ) { + Text("open_settings") + } + .padding(.bottom, 16) + + if viewModel.needsTracking { + VStack(alignment: .leading) { + HStack(alignment: .center) { + Toggle(isOn: viewModel.allowTrackingBinding) { + Text( + SharedRes + .strings() + .app_tracking_dialog_title + .desc() + .localized() + ) + } + } + Divider() + Text( + SharedRes + .strings() + .app_tracking_dialog_message + .desc() + .localized() + ) + } + .padding(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.more.secondary, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.bottom, 8) + + } + Text("settings_text") .foregroundColor(.more.secondary) .padding(.bottom, 15) @@ -36,6 +75,12 @@ struct SettingsView: View { Spacer() } .customNavigationTitle(with: NavigationScreen.settings.localize()) + .onAppear { + viewModel.coreViewModel.viewDidAppear() + } + .onDisappear { + viewModel.coreViewModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Settings/SettingsViewModel.swift b/iosApp/iosApp/Views/Settings/SettingsViewModel.swift index fd9b05a17..911b53de8 100644 --- a/iosApp/iosApp/Views/Settings/SettingsViewModel.swift +++ b/iosApp/iosApp/Views/Settings/SettingsViewModel.swift @@ -13,37 +13,81 @@ // https://commonsclause.com/). // +import AppTrackingTransparency import Combine import Foundation import KMPNativeCoroutinesCombine +import SwiftUI import shared class SettingsViewModel: ObservableObject { - private let coreSettingsViewModel = CoreSettingsViewModel(shared: AppDelegate.shared) + let coreViewModel: CoreSettingsViewModel @Published var studyTitle: String? @Published var permissionModel: PermissionModel? @Published var showSettings = false + @Published var allowTracking = ATTrackingManager.trackingAuthorizationStatus == .authorized + @Published var needsTracking = false private var cancellables: Set = [] + private let permissionManager = PermissionManager() - init() { - createPublisher(for: coreSettingsViewModel.study) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] studyEntity in - self?.studyTitle = studyEntity?.studyTitle - } - .store(in: &cancellables) - - createPublisher(for: coreSettingsViewModel.permissionModel) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] permissions in - self?.permissionModel = permissions - } - .store(in: &cancellables) + init(viewIdentifier: String = NavigationRoute.settings.viewIdentifier) { + coreViewModel = CoreSettingsViewModel(mainRepository: AppDelegate.shared.repositories, sharedStorageRepository: AppDelegate.shared.sharedStorageRepository, customViewIdentifier: viewIdentifier) + coreViewModel.setExitStudyObserver(observer: AppDelegate.shared) + createPublisher(for: coreViewModel.study) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] studyEntity in + self?.studyTitle = studyEntity?.studyTitle + } + .store(in: &cancellables) + + createPublisher(for: coreViewModel.permissionModel) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] permissions in + self?.permissionModel = permissions + } + .store(in: &cancellables) + + createPublisher(for: coreViewModel.allowTracking) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] allowTracking in + if ATTrackingManager.trackingAuthorizationStatus == .authorized { + self?.allowTracking = allowTracking.boolValue + } + } + .store(in: &cancellables) + + createPublisher(for: coreViewModel.needsTracking) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] needsTracking in + self?.needsTracking = needsTracking.boolValue + } + .store(in: &cancellables) + } + + var allowTrackingBinding: Binding { + Binding( + get: { self.allowTracking }, + set: { [weak self] newValue in + if ATTrackingManager.trackingAuthorizationStatus != .denied { + if newValue { + Task { @MainActor in + self?.permissionManager.requestAppTrackingAuthorization() + } + } + self?.allowTracking = newValue + self?.coreViewModel.setTrackingPermission(allow: newValue) + } else { + self?.coreViewModel.showAppTrackingPermissionDialog() + } + } + ) } func leaveStudy() { - coreSettingsViewModel.exitStudy() + coreViewModel.exitStudy() } } diff --git a/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift b/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift index 4122b6912..72b949f78 100644 --- a/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift +++ b/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift @@ -18,7 +18,7 @@ import KMPNativeCoroutinesCombine import shared class StudyDetailsViewModel: ObservableObject { - private let coreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared) + private let coreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared, customViewIdentifier: nil) @Published var studyDetailsModel: StudyDetailsModel? var studyStart: Date = Date() var studyEnd: Date = Date() diff --git a/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift b/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift index 5f14a8c2a..101be69dd 100644 --- a/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift +++ b/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift @@ -34,7 +34,7 @@ struct StudyClosedView: View { } } MoreActionButton(disabled: .constant(false)) { - AppDelegate.shared.exitStudy(onDeletion: {}) + AppDelegate.shared.exitStudy(onComplete: {}) } label: { BasicText(text: "Leave Study", color: .more.white, font: .headline) } diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 8677d3c50..3b5490bb1 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,6 +1,7 @@ // import BackgroundTasks +import SwiftUI import shared // Copyright © 2023 Ludwig Boltzmann Institute for @@ -13,7 +14,6 @@ import shared // https://commonsclause.com/). // -import SwiftUI @main struct iOSApp: App { @@ -24,25 +24,28 @@ struct iOSApp: App { var body: some Scene { WindowGroup { ContentView(viewModel: contentViewModel) - .onAppear { - } - .onChange(of: scenePhase) { newPhase in - switch newPhase { - case .background: - AppDelegate.shared.updateData(appInForeground: false) - if AppDelegate.shared.credentialRepository.hasCredentialsValue { - appDelegate.scheduleTasks() + .onAppear { + } + .onChange(of: scenePhase) { newPhase in + switch newPhase { + case .background: + ViewManager.shared.appIsInForeground(state: false) + AppDelegate.shared.updateData(appInForeground: false) + if AppDelegate.shared.credentialRepository.hasCredentialsValue { + appDelegate.scheduleTasks() + } + case .inactive: + break + case .active: + ViewManager.shared.appIsInForeground(state: true) + PermissionManager.resetPermissionAlertFlag() + AppDelegate.shared.updateData(appInForeground: true) + appDelegate.cancelBackgroundTasks() + break + default: + break } - case .inactive: - break - case .active: - AppDelegate.shared.updateData(appInForeground: true) - appDelegate.cancelBackgroundTasks() - break - default: - break } - } } } } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 0d0e5e82c..02881cd60 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,3 +1,15 @@ +/* + * 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/). + */ + +import org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.openapitools.generator.gradle.plugin.tasks.GenerateTask @@ -116,7 +128,7 @@ tasks.withType { testLogging { events("passed", "skipped", "failed") showStandardStreams = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + exceptionFormat = FULL } } diff --git a/shared/src/androidMain/kotlin/io/redlink/more/util/PlatformUtils.kt b/shared/src/androidMain/kotlin/io/redlink/more/util/PlatformUtils.kt new file mode 100644 index 000000000..dc8fd6fbe --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/util/PlatformUtils.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.util + +import io.redlink.more.viewModels.ViewManager + +actual fun openSystemSettings() { + ViewManager.showSettingsView(true) +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt index 0936c4ebd..fa0412097 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt @@ -16,6 +16,8 @@ import dev.tmapps.konnection.Konnection import io.github.aakira.napier.Napier import io.redlink.more.database.repository.MainRepository import io.redlink.more.extensions.toStudyState +import io.redlink.more.logging.EventCollection +import io.redlink.more.logging.EventObserver import io.redlink.more.models.StudyState import io.redlink.more.navigation.DeeplinkManager import io.redlink.more.navigation.DeeplinkManagerImpl @@ -43,6 +45,7 @@ import io.redlink.more.services.store.SharedStorageRepository import io.redlink.more.viewModels.ViewManager import io.redlink.more.viewModels.bluetoothConnection.BluetoothController import io.redlink.more.viewModels.garminConnectOAuth.CoreGarminConnectViewModel +import io.redlink.more.viewModels.settings.ExitStudyListener import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers @@ -69,7 +72,7 @@ open class Shared( reminderNotificationSchedulingLimit: Int? = null, val connectionStatusFlow: Flow = konnectionInstance().observeHasConnection() -) : NotificationActionObserver, AutoCloseable { +) : NotificationActionObserver, ExitStudyListener, AutoCloseable { val deeplinkManager: DeeplinkManager = DeeplinkManagerImpl(repositories, observationFactory) val endpointRepository: EndpointRepository = EndpointRepositoryImpl(sharedStorageRepository) val credentialRepository: CredentialRepository = @@ -108,6 +111,8 @@ open class Shared( private var mainJob: Job? = null init { + observationFactory.observationsWithInterface(EventObserver::class) + .forEach { EventCollection.addObserver(it) } val handler = CoroutineExceptionHandler { _, t -> Napier.e(tag = "Shared::init") { "Init watcher crashed: ${t.stackTraceToString()}" } } @@ -153,7 +158,6 @@ open class Shared( } fun updateData(appInForeground: Boolean) { - ViewManager.appIsInForeground(appInForeground) Scope.launch { Napier.d(tag = "Shared:updateData") { "Updating data, with app in foreground: $appInForeground" } if (appInForeground) { @@ -176,6 +180,7 @@ open class Shared( } else { ViewManager.showBLEView(false) if (repositories.study.studyState.value == StudyState.ACTIVE) { + observationDataManager.store() observationDataManager.sendData(true) } } @@ -183,6 +188,8 @@ open class Shared( } suspend fun updateSchedules() { + observationFactory.observationsWithInterface(EventObserver::class) + .forEach { EventCollection.addObserver(it) } observationManager.updateTaskStates() observationFactory.updateObservationErrors() observationService.scheduleObservationReminder() @@ -374,7 +381,7 @@ open class Shared( } } - fun exitStudy(onDeletion: () -> Unit) { + override fun exitStudy(onComplete: () -> Unit) { StudyScope.cancel() bluetoothController.resetAll() Scope.launch { @@ -383,10 +390,11 @@ open class Shared( observationService.clearReminders() notificationManager.clearAllNotifications() removeStudyData() - observationFactory.clearNeededObservationTypes() - onDeletion() + observationFactory.onStudyExit() + onComplete() ViewManager.resetAll() clearSharedStorage() + EventCollection.clearQueue() } } 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 aa563cf1a..63f54302f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt @@ -1,8 +1,20 @@ +/* + * 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 import androidx.room.ConstructedBy import androidx.room.Database 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.NotificationDao @@ -10,6 +22,7 @@ import io.redlink.more.database.dao.ObservationDao import io.redlink.more.database.dao.ObservationDataDao import io.redlink.more.database.dao.ScheduleDao import io.redlink.more.database.dao.StudyDao +import io.redlink.more.database.entities.AggregatedObservationDataEntity import io.redlink.more.database.entities.BluetoothDeviceEntity import io.redlink.more.database.entities.DataPointEntity import io.redlink.more.database.entities.NotificationEntity @@ -26,9 +39,10 @@ import io.redlink.more.database.entities.StudyEntity ObservationDataEntity::class, NotificationEntity::class, BluetoothDeviceEntity::class, - DataPointEntity::class + DataPointEntity::class, + AggregatedObservationDataEntity::class ], - version = 2 + version = 3 ) @ConstructedBy(AppDatabaseConstructor::class) abstract class AppDatabase : RoomDatabase() { @@ -39,4 +53,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun notificationDao(): NotificationDao abstract fun bluetoothDeviceDao(): BluetoothDeviceDao abstract fun dataPointDao(): DataPointDao + abstract fun aggregatedObservationDataDao(): AggregatedObservationDataDao } \ 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 f0572c85a..c09ffe75e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt @@ -14,6 +14,7 @@ import androidx.room.RoomDatabase 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -26,5 +27,5 @@ fun getRoomDatabase(builder: RoomDatabase.Builder): AppDatabase = builder .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build() \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/AggregatedObservationDataDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/AggregatedObservationDataDao.kt new file mode 100644 index 000000000..5f5abad70 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/AggregatedObservationDataDao.kt @@ -0,0 +1,59 @@ +/* + * 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.Query +import io.redlink.more.database.entities.AggregatedObservationDataEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface AggregatedObservationDataDao : BaseDao { + + @Query("SELECT * FROM aggregated_observation_data WHERE id = :id") + suspend fun getById(id: String): AggregatedObservationDataEntity? + + @Query("SELECT * FROM aggregated_observation_data WHERE id = :id") + fun getByIdFlow(id: String): Flow + + @Query("SELECT * FROM aggregated_observation_data WHERE observationId = :observationId") + suspend fun getByObservationId(observationId: String): List + + @Query("SELECT * FROM aggregated_observation_data WHERE observationId = :observationId") + fun getByObservationIdFlow(observationId: String): Flow> + + @Query("SELECT * FROM aggregated_observation_data WHERE observationType = :observationType") + suspend fun getByObservationType(observationType: String): List + + @Query("SELECT * FROM aggregated_observation_data WHERE observationType = :observationType") + fun getByObservationTypeFlow(observationType: String): Flow> + + @Query("SELECT * FROM aggregated_observation_data") + suspend fun getAll(): List + + @Query("SELECT * FROM aggregated_observation_data") + fun getAllFlow(): Flow> + + @Query("DELETE FROM aggregated_observation_data WHERE id = :id") + suspend fun deleteById(id: String) + + @Query("DELETE FROM aggregated_observation_data WHERE observationId = :observationId") + suspend fun deleteByObservationId(observationId: String) + + @Query("DELETE FROM aggregated_observation_data") + suspend fun deleteAll() + + @Query("SELECT COUNT(*) FROM aggregated_observation_data") + suspend fun getCount(): Int + + @Query("SELECT COUNT(*) FROM aggregated_observation_data WHERE observationId = :observationId") + suspend fun getCountByObservationId(observationId: String): Int +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt index e8f6901fd..54078c0a9 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt @@ -113,6 +113,9 @@ interface ScheduleDao : BaseDao { @Query("SELECT COUNT(*) FROM schedules WHERE observationId = :observationId") suspend fun getCountByObservationId(observationId: String): Int + @Query("SELECT DISTINCT observationType FROM schedules WHERE scheduleId IN (:scheduleIds)") + fun getObservationTypesForScheduleIds(scheduleIds: Set): Flow> + @Query("UPDATE schedules SET done = :done WHERE scheduleId = :scheduleId") suspend fun updateDoneStatus(scheduleId: String, done: Boolean) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/AggregatedObservationDataEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/AggregatedObservationDataEntity.kt new file mode 100644 index 000000000..c9d4597d9 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/AggregatedObservationDataEntity.kt @@ -0,0 +1,123 @@ +/* + * 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.Ignore +import androidx.room.PrimaryKey +import io.redlink.more.util.createUUID +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement + +@Entity(tableName = "aggregated_observation_data") +data class AggregatedObservationDataEntity( + @PrimaryKey val id: String = createUUID(), + val observationId: String = "", + val observationType: String = "", + val startTimestamp: Long = Clock.System.now().toEpochMilliseconds(), + val endTimestamp: Long = Clock.System.now().toEpochMilliseconds(), + val data: String? = null, + val metadata: String = "" +) { + @Serializable + data class TimestampedData( + val timestamp: Long, + val data: T + ) + + @Ignore + inline fun getDataAs(): T? { + return data?.let { + Json.decodeFromJsonElement(Json.parseToJsonElement(it)) + } + } + + @Ignore + inline fun update( + data: T, + metadata: String = this.metadata, + timestamp: Long = Clock.System.now().toEpochMilliseconds() + ): AggregatedObservationDataEntity { + val currentData: List> = + getDataAs>>() ?: emptyList() + val newData = currentData + TimestampedData(timestamp, data) + return this.copy( + data = Json.encodeToString(Json.encodeToJsonElement(newData)), + metadata = metadata, + endTimestamp = timestamp + ) + } + + @Ignore + inline fun update(data: AggregatedObservationDataEntity): AggregatedObservationDataEntity? { + return data.getDataAs()?.let { + return this.update(it, data.metadata, data.endTimestamp) + } + } + + @Serializable + data class PendingObservationPayload( + val startTimestamp: Long, + val endTimestamp: Long, + val metadata: String, + val payload: T? + ) + + @Ignore + inline fun toObservationDataEntity(): ObservationDataEntity { + return ObservationDataEntity( + observationId = observationId, + observationType = observationType, + dataValue = Json.encodeToString( + PendingObservationPayload( + startTimestamp = startTimestamp, + endTimestamp = endTimestamp, + metadata = metadata, + payload = getDataAs() + ) + ), + timestamp = endTimestamp + ) + } + + companion object { + inline fun fromData( + observationId: String, + observationType: String, + data: T, + startTimestamp: Long = Clock.System.now().toEpochMilliseconds(), + endTimestamp: Long = Clock.System.now().toEpochMilliseconds(), + metadata: String = "" + ): AggregatedObservationDataEntity { + return AggregatedObservationDataEntity( + observationId = observationId, + observationType = observationType, + data = Json.encodeToString( + Json.encodeToJsonElement( + listOf( + TimestampedData( + startTimestamp, + data + ) + ) + ) + ), + startTimestamp = startTimestamp, + endTimestamp = endTimestamp, + metadata = metadata + ) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt index a9ab2d5f8..f19f81296 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt @@ -66,7 +66,7 @@ data class ObservationDataEntity( ) } - fun fromData(data: Any, timestamp: Long = -1): ObservationDataEntity { + inline fun fromData(data: T, timestamp: Long = -1): ObservationDataEntity { val finalTimestamp = if (timestamp > 0) { timestamp * 1000 // Convert to milliseconds } else { @@ -75,7 +75,7 @@ data class ObservationDataEntity( return ObservationDataEntity( timestamp = finalTimestamp, - dataValue = data.asString() ?: "{}" + dataValue = data?.asString() ?: "{}" ) } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_2_3.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_2_3.kt new file mode 100644 index 000000000..bb775e984 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_2_3.kt @@ -0,0 +1,22 @@ +/* + * 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_2_3 = object : Migration(2, 3) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL("CREATE TABLE IF NOT EXISTS `aggregated_observation_data` (`id` TEXT NOT NULL, `observationId` TEXT NOT NULL, `observationType` TEXT NOT NULL, `startTimestamp` INTEGER NOT NULL, `endTimestamp` INTEGER NOT NULL, `data` TEXT, `metadata` TEXT NOT NULL, PRIMARY KEY(`id`))") + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepository.kt new file mode 100644 index 000000000..7b01da8cb --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepository.kt @@ -0,0 +1,39 @@ +/* + * 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.repository + +import io.redlink.more.database.entities.AggregatedObservationDataEntity +import kotlinx.coroutines.flow.Flow + +interface AggregatedObservationDataRepository { + suspend fun insert(entity: AggregatedObservationDataEntity) + suspend fun insertAll(entities: List) + suspend fun update(entity: AggregatedObservationDataEntity) + suspend fun delete(entity: AggregatedObservationDataEntity) + suspend fun deleteById(id: String) + suspend fun deleteByObservationId(observationId: String) + suspend fun deleteAll() + + suspend fun getById(id: String): AggregatedObservationDataEntity? + fun getByIdFlow(id: String): Flow + + suspend fun getByObservationId(observationId: String): List + fun getByObservationIdFlow(observationId: String): Flow> + + suspend fun getByObservationType(observationType: String): List + fun getByObservationTypeFlow(observationType: String): Flow> + + suspend fun getAll(): List + fun getAllFlow(): Flow> + + suspend fun getCount(): Int + suspend fun getCountByObservationId(observationId: String): Int +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepositoryImpl.kt new file mode 100644 index 000000000..67fd0d776 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepositoryImpl.kt @@ -0,0 +1,70 @@ +/* + * 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.repository + +import io.redlink.more.database.AppDatabase +import io.redlink.more.database.entities.AggregatedObservationDataEntity +import kotlinx.coroutines.flow.Flow + +class AggregatedObservationDataRepositoryImpl(private val appDatabase: AppDatabase) : + AggregatedObservationDataRepository { + + override suspend fun insert(entity: AggregatedObservationDataEntity) = + appDatabase.aggregatedObservationDataDao().insert(entity) + + override suspend fun insertAll(entities: List) = + appDatabase.aggregatedObservationDataDao().insertAll(entities) + + override suspend fun update(entity: AggregatedObservationDataEntity) = + appDatabase.aggregatedObservationDataDao().update(entity) + + override suspend fun delete(entity: AggregatedObservationDataEntity) = + appDatabase.aggregatedObservationDataDao().delete(entity) + + override suspend fun deleteById(id: String) = + appDatabase.aggregatedObservationDataDao().deleteById(id) + + override suspend fun deleteByObservationId(observationId: String) = + appDatabase.aggregatedObservationDataDao().deleteByObservationId(observationId) + + override suspend fun deleteAll() = + appDatabase.aggregatedObservationDataDao().deleteAll() + + override suspend fun getById(id: String): AggregatedObservationDataEntity? = + appDatabase.aggregatedObservationDataDao().getById(id) + + override fun getByIdFlow(id: String): Flow = + appDatabase.aggregatedObservationDataDao().getByIdFlow(id) + + override suspend fun getByObservationId(observationId: String): List = + appDatabase.aggregatedObservationDataDao().getByObservationId(observationId) + + override fun getByObservationIdFlow(observationId: String): Flow> = + appDatabase.aggregatedObservationDataDao().getByObservationIdFlow(observationId) + + override suspend fun getByObservationType(observationType: String): List = + appDatabase.aggregatedObservationDataDao().getByObservationType(observationType) + + override fun getByObservationTypeFlow(observationType: String): Flow> = + appDatabase.aggregatedObservationDataDao().getByObservationTypeFlow(observationType) + + override suspend fun getAll(): List = + appDatabase.aggregatedObservationDataDao().getAll() + + override fun getAllFlow(): Flow> = + appDatabase.aggregatedObservationDataDao().getAllFlow() + + override suspend fun getCount(): Int = + appDatabase.aggregatedObservationDataDao().getCount() + + override suspend fun getCountByObservationId(observationId: String): Int = + appDatabase.aggregatedObservationDataDao().getCountByObservationId(observationId) +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt index ea41de51a..f0cf9f485 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt @@ -1,3 +1,14 @@ +/* + * 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.repository interface MainRepository { @@ -9,6 +20,7 @@ interface MainRepository { val notification: NotificationRepository val bluetoothDevice: BluetoothDeviceRepository + val aggregatedObservationData: AggregatedObservationDataRepository suspend fun deleteAll() } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepositoryImpl.kt index 33b71d30d..257999397 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepositoryImpl.kt @@ -1,3 +1,14 @@ +/* + * 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.repository import io.redlink.more.database.AppDatabase @@ -14,9 +25,12 @@ class MainRepositoryImpl(appDatabase: AppDatabase) : MainRepository { override val notification: NotificationRepository = NotificationRepositoryImpl(appDatabase) override val bluetoothDevice: BluetoothDeviceRepository = BluetoothDeviceRepositoryImpl(appDatabase) + override val aggregatedObservationData: AggregatedObservationDataRepository = + AggregatedObservationDataRepositoryImpl(appDatabase) override suspend fun deleteAll() { study.deleteStudy() notification.deleteAll() + aggregatedObservationData.deleteAll() } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt index 054abd2cd..da2e7a913 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt @@ -41,6 +41,8 @@ interface ScheduleRepository { fun firstScheduleIdAvailableForObservationId(observationId: String): Flow + fun observationTypesForScheduleIds(scheduleIds: Set): Flow> + fun getFirstAndLastDate(observationId: String): Flow> suspend fun setRunningStateFor(id: String, scheduleState: ScheduleState) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepositoryImpl.kt index ac3126f83..ae237ba52 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepositoryImpl.kt @@ -19,7 +19,9 @@ import io.redlink.more.observations.ObservationFactory import io.redlink.more.observations.observationTypes.ObservationType import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.transform import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -102,6 +104,11 @@ class ScheduleRepositoryImpl(private val appDatabase: AppDatabase) : ScheduleRep override fun firstScheduleIdAvailableForObservationId(observationId: String): Flow = firstScheduleAvailableForObservationId(observationId).transform { it?.scheduleId } + override fun observationTypesForScheduleIds(scheduleIds: Set): Flow> { + return appDatabase.scheduleDao().getObservationTypesForScheduleIds(scheduleIds) + .map { it.toSet() } + } + override fun getFirstAndLastDate(observationId: String): Flow> { return appDatabase.scheduleDao().getByObservationIdFlow(observationId).transform { val start = it.sortedBy { it.start }.firstOrNull() @@ -136,7 +143,11 @@ class ScheduleRepositoryImpl(private val appDatabase: AppDatabase) : ScheduleRep Napier.i { "Updating Schedule states..." } try { - val schedules = appDatabase.scheduleDao().getByDone(false) + val schedules = appDatabase + .scheduleDao() + .getByStatesFlow(ScheduleState.presentScheduleStates.map { it.name }) + .firstOrNull() + ?: emptyList() val stateUpdates = mutableListOf>() val activeIds = mutableSetOf() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt b/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertController.kt similarity index 69% rename from shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt rename to shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertController.kt index e52d18411..4e67ebd0f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertController.kt @@ -1,8 +1,18 @@ -package io.redlink.more +/* + * 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.dialog import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.redlink.more.extensions.then -import io.redlink.more.models.AlertDialogModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertDialogModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertDialogModel.kt new file mode 100644 index 000000000..957e2a37c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertDialogModel.kt @@ -0,0 +1,66 @@ +/* + * 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.dialog + +import dev.icerock.moko.resources.desc.Raw +import dev.icerock.moko.resources.desc.StringDesc + +data class AlertDialogModel( + var title: StringDesc, + var message: StringDesc, + var confirmLabel: StringDesc, + var cancelLabel: StringDesc? = null, + var onConfirm: (() -> Unit)? = null, + var onDecline: (() -> Unit)? = null +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as AlertDialogModel + + if (title != other.title) return false + if (message != other.message) return false + if (confirmLabel != other.confirmLabel) return false + if (cancelLabel != other.cancelLabel) return false + + return true + } + + override fun hashCode(): Int { + var result = title.hashCode() + result = 31 * result + message.hashCode() + result = 31 * result + confirmLabel.hashCode() + result = 31 * result + (cancelLabel?.hashCode() ?: 0) + return result + } + + companion object { + fun fromStrings( + title: String, + message: String, + confirmLabel: String, + cancelLabel: String? = null, + onConfirm: (() -> Unit)? = null, + onDecline: (() -> Unit)? = null + ): AlertDialogModel { + return AlertDialogModel( + StringDesc.Raw(title), + StringDesc.Raw(message), + StringDesc.Raw(confirmLabel), + cancelLabel?.let { StringDesc.Raw(it) }, + onConfirm, + onDecline + ) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/logging/EventCollection.kt b/shared/src/commonMain/kotlin/io/redlink/more/logging/EventCollection.kt new file mode 100644 index 000000000..82093ceec --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/logging/EventCollection.kt @@ -0,0 +1,52 @@ +/* + * 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.logging + +import io.redlink.more.observations.appUsage.model.LogEvent + +interface EventObserver { + fun onEvent(event: LogEvent, message: String?) +} + +private data class LogEventQueueItem(val event: LogEvent, val message: String?) + +object EventCollection { + private val observers = mutableListOf() + + private val eventQueue = mutableListOf() + + fun addObserver(observer: EventObserver) { + if (!observers.contains(observer)) { + observers.add(observer) + eventQueue.forEach { + logEvent(it.event, it.message) + } + eventQueue.clear() + } + } + + fun removeObserver(observer: EventObserver) { + observers.remove(observer) + } + + fun logEvent(event: LogEvent, message: String? = null) { + if (observers.isEmpty()) { + eventQueue.add(LogEventQueueItem(event, message)) + } else { + observers.forEach { it.onEvent(event, message) } + } + } + + fun clearQueue() { + eventQueue.clear() + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/logging/KMMLogger.kt b/shared/src/commonMain/kotlin/io/redlink/more/logging/KMMLogger.kt new file mode 100644 index 000000000..71c156db6 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/logging/KMMLogger.kt @@ -0,0 +1,44 @@ +/* + * 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.logging + +import io.github.aakira.napier.Napier +import io.redlink.more.observations.appUsage.model.LogEvent + +object KMMLogger { + const val EVENT_TAG = "EVENT" + fun d(tag: String? = null, message: String) { + Napier.d(message, tag = tag) + } + + fun i(tag: String? = null, message: String) { + Napier.i(message, tag = tag) + } + + fun w(tag: String? = null, message: String) { + Napier.w(message, tag = tag) + } + + fun e(tag: String? = null, message: String) { + Napier.e(message, tag = tag) + } + + fun event(event: LogEvent, message: String? = null) { + val logMessage = "[EVENT: ${event.key}] ${message ?: ""}".trim() + Napier.i(logMessage, tag = EVENT_TAG) + EventCollection.logEvent(event, message) + } +} + +fun Napier.event(event: LogEvent, message: String? = null) { + KMMLogger.event(event, message) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/NapierProxy.kt b/shared/src/commonMain/kotlin/io/redlink/more/logging/NapierProxy.kt similarity index 86% rename from shared/src/commonMain/kotlin/io/redlink/more/NapierProxy.kt rename to shared/src/commonMain/kotlin/io/redlink/more/logging/NapierProxy.kt index 122195af8..010389118 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/NapierProxy.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/logging/NapierProxy.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more +package io.redlink.more.logging import io.github.aakira.napier.Antilog import io.github.aakira.napier.DebugAntilog @@ -16,4 +16,8 @@ import io.github.aakira.napier.Napier fun napierDebugBuild(antilog: Antilog? = null) { Napier.base(antilog ?: DebugAntilog()) +} + +fun addNapierLogChannel(antilog: Antilog) { + Napier.base(antilog) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt deleted file mode 100644 index e0806f96f..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.redlink.more.models - -data class AlertDialogModel( - var title: String, - var message: String, - var confirmLabel: String, - var cancelLabel: String? = null, - var onConfirm: (() -> Unit)? = null, - var onDecline: (() -> Unit)? = null -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as AlertDialogModel - - if (title != other.title) return false - if (message != other.message) return false - if (confirmLabel != other.confirmLabel) return false - if (cancelLabel != other.cancelLabel) return false - - return true - } - - override fun hashCode(): Int { - var result = title.hashCode() - result = 31 * result + message.hashCode() - result = 31 * result + confirmLabel.hashCode() - result = 31 * result + (cancelLabel?.hashCode() ?: 0) - return result - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleState.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleState.kt index 9006d8172..7255cedb9 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleState.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleState.kt @@ -29,5 +29,6 @@ enum class ScheduleState { companion object { fun getState(name: String) = entries.firstOrNull { it.name == name } ?: DEACTIVATED + val presentScheduleStates = listOf(DEACTIVATED, ACTIVE, RUNNING, PAUSED) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt index afe78b330..b5c17c9b6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt @@ -1,28 +1,47 @@ +/* + * 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.navigation.model import io.redlink.more.observations.observationTypes.GarminType import io.redlink.more.observations.observationTypes.LimeSurveyType import io.redlink.more.observations.observationTypes.QuestionType -enum class NavigationRoute(val route: String) { - DASHBOARD("dashboard"), - NOTIFICATIONS("notifications"), - INFO("information"), - SETTINGS("settings"), - SCHEDULE_DETAILS("task-details"), - OBSERVATION_DETAILS("observation-details"), - STUDY_DETAILS("study-details"), - OBSERVATION_FILTER("observation-filter"), - QUESTION(QuestionType().observationType), - QUESTIONNAIRE_RESPONSE("${QuestionType().observationType}_response"), - BLUETOOTH_CONNECTION("devices"), - RUNNING_SCHEDULES("running-observations"), - COMPLETED_SCHEDULES("past-observations"), - NOTIFICATION_FILTER("notification-filter"), - LEAVE_STUDY("leave-study"), - LEAVE_STUDY_CONFIRM("leave-study-confirmation"), - LIMESURVEY(LimeSurveyType().observationType), - GARMIN_CONNECT(GarminType().observationType), - OBSERVATION_ERRORS("observation-errors"), - QR_CODE("scan-qr-code"); +enum class NavigationRoute(val route: String, val viewIdentifier: String) { + LOGIN("login", "Login"), + CONSENT("consent", "Consent"), + DASHBOARD("dashboard", "Dashboard/Manual tasks"), + NOTIFICATIONS("notifications", "Notifications"), + INFO("information", "Information Menu"), + SETTINGS("settings", "Settings"), + SCHEDULE_DETAILS("task-details", "Task Detail Information"), + OBSERVATION_DETAILS("observation-details", "Observation Detail Information"), + STUDY_DETAILS("study-details", "Study Detail Information"), + OBSERVATION_FILTER("observation-filter", "Observation Filter"), + QUESTION( + QuestionType().observationType, + "${QuestionType().observationType} Questionnaire Interaction" + ), + QUESTIONNAIRE_RESPONSE( + "${QuestionType().observationType}_response", + "${QuestionType().observationType} Questionnaire Response" + ), + BLUETOOTH_CONNECTION("devices", "Bluetooth Connections"), + RUNNING_SCHEDULES("running-observations", "Running Observation List"), + COMPLETED_SCHEDULES("past-observations", "Completed Observation List"), + NOTIFICATION_FILTER("notification-filter", "Notification Filter"), + LEAVE_STUDY("leave-study", "Study Exit"), + LEAVE_STUDY_CONFIRM("leave-study-confirmation", "Study Exit Confirmation"), + LIMESURVEY(LimeSurveyType().observationType, "LimeSurvey Interaction"), + GARMIN_CONNECT(GarminType().observationType, "Garmin Connect Login"), + OBSERVATION_ERRORS("observation-errors", "Observation Errors"), + QR_CODE("scan-qr-code", "Scan QR Code"); } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt index 1cc876795..579e9b43c 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt @@ -15,10 +15,12 @@ import io.redlink.more.database.entities.NotificationEntity import io.redlink.more.database.entities.ObservationDataEntity import io.redlink.more.database.repository.MainRepository import io.redlink.more.models.ScheduleState +import io.redlink.more.observations.longRunningObservation.LongRunningObservationStorage import io.redlink.more.observations.observationTypes.ObservationType import io.redlink.more.scopes.Scope import io.redlink.more.scopes.StudyScope import io.redlink.more.services.notification.NotificationManager +import io.redlink.more.services.store.PermissionApprovalState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job @@ -29,17 +31,28 @@ import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import kotlinx.datetime.Instant +interface ObservationPermissionObserver { + fun requestPermission(observationType: ObservationType) + fun permissionState(observationType: ObservationType): PermissionApprovalState +} + abstract class Observation( - private val repos: MainRepository, - val observationType: ObservationType + protected val repos: MainRepository, + val observationType: ObservationType, ) { private var dataManager: ObservationDataManager? = null private var notificationManager: NotificationManager? = null - private var running = false - private val observationIds = mutableSetOf() + private var permissionObserver: ObservationPermissionObserver? = null + + protected var running = false + protected val observationIds = mutableSetOf() + + protected var longRunningStorage: LongRunningObservationStorage? = null + + private val observationTypes = mutableMapOf() - private val scheduleIds = mutableMapOf() + protected val scheduleIds = mutableMapOf() private val notificationIds = mutableMapOf() private val config = mutableMapOf() private var configChanged = false @@ -48,6 +61,15 @@ abstract class Observation( var timestampCollectionJob: Job? = null + fun setPermissionObserver(observer: ObservationPermissionObserver?) { + permissionObserver = observer + Napier.d { "PermissionObserver set for ${observationTypes.values.joinToString(", ")}" } + } + + fun setLongRunningObservationStorage(longRunningObservationStorage: LongRunningObservationStorage?) { + this.longRunningStorage = longRunningObservationStorage + } + open fun start( observationId: String, scheduleId: String, @@ -122,6 +144,25 @@ abstract class Observation( notificationIds[scheduleId] = notificationId } + fun requestPermission() { + if (isPermissionRequested(observationType.observationType)) { + Napier.d(tag = "Observation::requestPermission") { "Permission already requested for ${observationType.observationType} in this session, skipping..." } + return + } + markPermissionRequested(observationType.observationType) + if (permissionObserver == null) { + Napier.w { "Permission observer is null for observation type ${observationType.observationType}" } + } + permissionObserver?.requestPermission(observationType) + } + + open fun hasPermission(): PermissionApprovalState { + return permissionObserver?.permissionState(observationType) ?: run { + Napier.w { "Permission observer is null for observation type ${observationType.observationType}" } + PermissionApprovalState.NOT_SET + } + } + fun observationConfig(settings: Map) { this.lastCollectionTimestamp = (settings[CONFIG_LAST_COLLECTION_TIMESTAMP] as? Long)?.let { Instant.fromEpochMilliseconds(it) @@ -183,7 +224,23 @@ abstract class Observation( open fun ableToAutomaticallyStart() = true - fun storeData(data: Any, timestamp: Long = -1, onCompletion: () -> Unit = {}) { + fun storeInstant(data: T, timestamp: Long) { + longRunningStorage?.storeInstant(data, timestamp) + } + + fun startLongRunningObservation(data: T, identifier: String, timestamp: Long) { + longRunningStorage?.startObservation(data, identifier, timestamp) + } + + fun finishLongRunningObservation(data: T, identifier: String, timestamp: Long) { + longRunningStorage?.finishObservation(data, identifier, timestamp) + } + + fun inRangeLongRunningObservation(data: T, identifier: String, timestamp: Long) { + longRunningStorage?.inRangeObservation(data, identifier, timestamp) + } + + fun storeData(data: Map, timestamp: Long = -1, onCompletion: () -> Unit = {}) { val dataSchemas = ObservationDataEntity.fromData( observationIds.toSet(), setOf(ObservationBulkModel(data, timestamp)) ).map { @@ -327,7 +384,24 @@ abstract class Observation( scheduleIds.clear() } + open fun onStudyExit() {} + companion object { + private val requestedPermissions = mutableSetOf() + + fun resetRequestedPermissions() { + Napier.d(tag = "Observation::companion") { "Resetting requested permissions" } + requestedPermissions.clear() + } + + fun markPermissionRequested(observationType: String) { + requestedPermissions.add(observationType) + } + + fun isPermissionRequested(observationType: String): Boolean { + return requestedPermissions.contains(observationType) + } + const val CONFIG_TASK_START = "observation_start_date_time" const val CONFIG_TASK_STOP = "observation_stop_date_time" const val SCHEDULE_ID = "schedule_id" diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationBulkModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationBulkModel.kt index 3bb85c989..f8402ad17 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationBulkModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationBulkModel.kt @@ -11,6 +11,6 @@ package io.redlink.more.observations data class ObservationBulkModel( - val data: Any, + val data: Map, val timestamp: Long = 0 ) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt index 7253e264a..b73c60ed1 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt @@ -12,20 +12,29 @@ package io.redlink.more.observations import io.github.aakira.napier.Napier import io.redlink.more.database.repository.MainRepository +import io.redlink.more.logging.EventCollection +import io.redlink.more.logging.EventObserver +import io.redlink.more.observations.appUsage.AppUsageObservation import io.redlink.more.observations.garmin.GarminObservation import io.redlink.more.observations.limesurvey.LimeSurveyObservation import io.redlink.more.observations.questionObservation.QuestionObservation +import io.redlink.more.scopes.AppDispatchers +import io.redlink.more.scopes.MoreScope import io.redlink.more.scopes.Scope import io.redlink.more.services.notification.NotificationManager import io.redlink.more.services.store.CredentialRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO +import io.redlink.more.services.store.PermissionRepository +import io.redlink.more.services.store.PermissionRepositoryImpl +import io.redlink.more.services.store.SharedStorageRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlin.reflect.KClass abstract class ObservationFactory( repository: MainRepository, - private val dataManager: ObservationDataManager + sharedStorageRepository: SharedStorageRepository, + private val dataManager: ObservationDataManager, + scope: MoreScope = Scope ) { private var credentialRepository: CredentialRepository? = null open val observations = mutableSetOf() @@ -33,25 +42,83 @@ abstract class ObservationFactory( private val _studyObservationTypes: MutableStateFlow> = MutableStateFlow(emptySet()) open val studyObservationTypes: StateFlow> = _studyObservationTypes + private val observationProviders = mutableSetOf<() -> Observation>() + + protected val permissionRepository = PermissionRepositoryImpl( + sharedStorageRepository + ) + + protected var appUsageObservation: AppUsageObservation? = null + init { - observations.add(QuestionObservation(repository)) - observations.add(LimeSurveyObservation(repository)) - observations.add(GarminObservation(repository)) - Scope.launch(Dispatchers.IO) { + registerImportantObservations(repository, permissionRepository) + registerNormalObservations(repository) + scope.launch(AppDispatchers.io) { repository.observation.observationTypes().collect { Napier.i(tag = "ObservationFactory::init") { "Observation types fetched: $it" } + initializeNeededObservations(it) _studyObservationTypes.value = it } } } + private fun registerImportantObservations( + repository: MainRepository, + permissionRepository: PermissionRepository + ) { + if (appUsageObservation == null) { + appUsageObservation = AppUsageObservation(repository, permissionRepository) + } + addObservationToList(appUsageObservation!!) + } + + private fun registerNormalObservations(repository: MainRepository) { + registerObservation { QuestionObservation(repository) } + registerObservation { LimeSurveyObservation(repository) } + registerObservation { GarminObservation(repository) } + } + + open fun observationPostConstruct(observation: Observation) {} + + protected fun registerObservation(provider: () -> Observation) { + observationProviders.add(provider) + } + + private fun initializeNeededObservations(types: Set) { + observationProviders.forEach { provider -> + val observation = provider() + if (observation.observationType.matchesAny(types)) { + if (observations.none { it.observationType.observationType == observation.observationType.observationType }) { + addObservationToList( + observation + ) + } + } + } + Napier.d("Initialized needed observations: ${observations.map { it.observationType.observationType }}") + } + + private fun addObservationToList(observation: Observation) { + observations.add( + observation + .also { observationPostConstruct(it) } + ) + } + open fun addNeededObservationTypes(observationTypes: Set) { Napier.i(tag = "ObservationFactory::addNeededObservationTypes") { "Adding observation types to studyObservationTypes: $observationTypes" } _studyObservationTypes.value += observationTypes + initializeNeededObservations(_studyObservationTypes.value) } open fun clearNeededObservationTypes() { _studyObservationTypes.value = setOf() + observationsWithInterface(EventObserver::class) + .forEach { EventCollection.removeObserver(it) } + observations.removeAll { + !EventObserver::class.isInstance(it) + } + Napier.d("Cleared needed observations, but ${observations.map { it.observationType.observationType }}") ObservationStates.resetAll() } @@ -101,9 +168,13 @@ abstract class ObservationFactory( open fun observation(type: String): Observation? { Napier.i(tag = "ObservationFactory::observation") { "Fetching observation of type: $type" } - return observations.firstOrNull { + val observation = observations.firstOrNull { it.observationType.matches(type) - }?.apply { + } ?: observationProviders.map { it() }.firstOrNull { it.observationType.matches(type) } + ?.also { + observations.add(it) + } + return observation?.apply { if (!this.observationDataManagerAdded()) { Napier.i(tag = "ObservationFactory::observation") { "Adding data manager to observation of type: $type" } setDataManager(dataManager) @@ -111,6 +182,16 @@ abstract class ObservationFactory( } } + fun observationsWithInterface(clazz: KClass): Set = + observations.filter { clazz.isInstance(it) } + .mapNotNull { it as? T } + .toSet() + + fun onStudyExit() { + observations.forEach { it.onStudyExit() } + clearNeededObservationTypes() + } + private fun studyObservations(): List = observations.filter { it.observationType.matchesAny(studyObservationTypes.value) } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt new file mode 100644 index 000000000..6c3afad7a --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt @@ -0,0 +1,380 @@ +/* + * 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.observations.appUsage + +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.database.repository.MainRepository +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.getPlatform +import io.redlink.more.logging.EventCollection +import io.redlink.more.logging.EventObserver +import io.redlink.more.observations.Observation +import io.redlink.more.observations.appUsage.model.EventModel +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.longRunningObservation.InMemoryLongRunningObservationStorage +import io.redlink.more.observations.observationTypes.AppUsageObservationType +import io.redlink.more.services.store.PermissionApprovalState +import io.redlink.more.services.store.PermissionRepository +import io.redlink.more.services.store.PermissionType +import io.redlink.more.util.openSystemSettings +import kotlinx.datetime.Clock + +class AppUsageObservation( + repos: MainRepository, + private val permissionRepository: PermissionRepository +) : Observation(repos, AppUsageObservationType()), EventObserver { + + private sealed class BufferedData { + data class Instant(val data: EventModel, val timestamp: Long) : BufferedData() + data class Range( + val eventKey: String, + val identifier: String, + val startTimestamp: Long, + val endTimestamp: Long, + val storeWithoutApproval: Boolean = false + ) : BufferedData() + } + + private val dataBuffer = mutableListOf() + + private val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { data, timestamp -> + if (data is EventModel) { + val logEvent = data.eventType + if ((trackingApproval == PermissionApprovalState.GRANTED || logEvent.storeWithoutApproval) && observationIds.isNotEmpty() && scheduleIds.isNotEmpty()) { + storeImmediate(data, timestamp) + } else { + dataBuffer.add(BufferedData.Instant(data, timestamp)) + persistBuffer() + } + } + }, + onFinish = { data, identifier, startTimestamp, endTimestamp -> + val dataList = data as? List<*> + val firstEventModel = dataList?.firstOrNull() as? Pair<*, *> + val firstEvent = (firstEventModel?.first as? EventModel)?.eventType + val eventKey = firstEvent?.family?.key ?: firstEvent?.key ?: "unknown" + val logEvent = firstEvent + if ((trackingApproval == PermissionApprovalState.GRANTED || logEvent?.storeWithoutApproval == true) && observationIds.isNotEmpty() && scheduleIds.isNotEmpty()) { + storeRange(eventKey, identifier, startTimestamp, endTimestamp) + } else { + dataBuffer.add( + BufferedData.Range( + eventKey, + identifier, + startTimestamp, + endTimestamp, + logEvent?.storeWithoutApproval == true + ) + ) + persistBuffer() + } + } + ) + private var trackingApproval: PermissionApprovalState = PermissionApprovalState.NOT_SET + + init { + setLongRunningObservationStorage(storage) + trackingApproval = permissionRepository.getPermission(PermissionType.APP_TRACKING) + loadBuffer() + EventCollection.addObserver(this) + } + + private fun loadBuffer() { + permissionRepository.loadValue(BUFFER_KEY)?.let { json -> + try { + val lines = json.split("\n").filter { it.isNotBlank() } + lines.forEach { line -> + val parts = line.split("|") + if (parts.size >= 2) { + when (parts[0]) { + "INSTANT" -> { + if (parts.size >= 5) { + val timestamp = parts[1].toLongOrNull() ?: 0L + val eventType = LogEvent.entries.find { it.key == parts[2] } + ?: LogEvent.OBSERVATION_EVENT + val eventTimestamp = parts[3].toLongOrNull() ?: 0L + val identifier = parts[4].takeIf { it != "null" } + val eventData = + identifier?.let { mapOf("identifier" to it) } ?: emptyMap() + dataBuffer.add( + BufferedData.Instant( + EventModel(eventTimestamp, eventType, eventData), + timestamp + ) + ) + } + } + + "RANGE" -> { + if (parts.size >= 6) { + val eventKey = parts[1] + val identifier = parts[2] + val startTimestamp = parts[3].toLongOrNull() ?: 0L + val endTimestamp = parts[4].toLongOrNull() ?: 0L + val storeWithoutApproval = parts[5].toBoolean() + dataBuffer.add( + BufferedData.Range( + eventKey, + identifier, + startTimestamp, + endTimestamp, + storeWithoutApproval + ) + ) + } else if (parts.size >= 5) { + val eventKey = parts[1] + val identifier = parts[2] + val startTimestamp = parts[3].toLongOrNull() ?: 0L + val endTimestamp = parts[4].toLongOrNull() ?: 0L + dataBuffer.add( + BufferedData.Range( + eventKey, + identifier, + startTimestamp, + endTimestamp + ) + ) + } + } + } + } + } + } catch (e: Exception) { + Napier.e(tag = "AppUsageObservation::loadBuffer") { "Failed to load buffer: ${e.message}" } + } + } + } + + private fun persistBuffer() { + val json = dataBuffer.joinToString("\n") { + when (it) { + is BufferedData.Instant -> { + val identifier = it.data.eventData["identifier"] as? String ?: "null" + "INSTANT|${it.timestamp}|${it.data.eventType.key}|${it.data.timestamp}|$identifier" + } + + is BufferedData.Range -> "RANGE|${it.eventKey}|${it.identifier}|${it.startTimestamp}|${it.endTimestamp}|${it.storeWithoutApproval}" + } + } + permissionRepository.storeValue(BUFFER_KEY, json) + } + + override fun start( + observationId: String, + scheduleId: String, + notificationId: String? + ): Boolean { + if (hasPermission() != PermissionApprovalState.GRANTED) { + requestPermission() + } + val started = super.start(observationId, scheduleId, notificationId) + flushBufferIfPossible() + return started + } + + override fun start(): Boolean { + return true + } + + override fun stop(onCompletion: () -> Unit) { + flushOpenRanges() + onCompletion() + } + + override fun applyObservationConfig(settings: Map) { + } + + override fun observerErrors(): Set { + val errors = mutableSetOf() + if (hasPermission() == PermissionApprovalState.DECLINED) { + Napier.e { "App tracking declined!" } + errors.add("app_usage_tracking_declined") + if (isPermissionRequested(observationType.observationType)) { + return errors + } + markPermissionRequested(observationType.observationType) + val isAndroid = getPlatform().name.lowercase().contains("android") + val messageRes = if (isAndroid) { + SharedRes.strings.app_usage_tracking_disabled_message_android + } else { + SharedRes.strings.app_usage_tracking_disabled_message_ios + } + val model = AlertDialogModel( + title = StringDesc.Resource(SharedRes.strings.app_usage_tracking_disabled_title), + message = StringDesc.Resource(messageRes), + confirmLabel = StringDesc.Resource(SharedRes.strings.app_usage_tracking_disabled_confirm), + cancelLabel = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_negative_button), + onConfirm = { openSystemSettings() } + ) + AlertController.openAlertDialog(model) + } + return errors + } + + override fun onStudyExit() { + (longRunningStorage as? InMemoryLongRunningObservationStorage)?.clear() + dataBuffer.clear() + permissionRepository.removeValue(BUFFER_KEY) + } + + override fun onEvent( + event: LogEvent, + message: String? + ) { + if (event == LogEvent.APP_TRACKING_ACCEPTED) { + if (trackingApproval != PermissionApprovalState.GRANTED) { + setTrackingApproval(true) + } else { + return + } + } + + if (event == LogEvent.APP_TRACKING_DECLINED) { + if (trackingApproval == PermissionApprovalState.GRANTED) { + flushOpenRanges() + } + if (trackingApproval != PermissionApprovalState.DECLINED) { + setTrackingApproval(false) + } + } + + val identifier = message?.takeIf { it.isNotBlank() } + val endTimestamp = Clock.System.now().toEpochMilliseconds() + val eventModel = EventModel( + timestamp = endTimestamp, + eventType = event, + eventData = identifier?.let { mapOf("identifier" to it) } ?: emptyMap() + ) + + when { + event.shouldStoreImmediately() -> { + storeInstant( + eventModel, + endTimestamp + ) + } + + event.isRangeStart() -> { + startLongRunningObservation(eventModel, identifier ?: event.key, endTimestamp) + } + + event.inRange() -> { + val startEvent = event.matchingStartEvent() ?: event + inRangeLongRunningObservation( + eventModel, + identifier ?: startEvent.key, + endTimestamp + ) + } + + event.isRangeEnd() -> { + val startEvent = event.matchingStartEvent() ?: event + finishLongRunningObservation(eventModel, identifier ?: startEvent.key, endTimestamp) + } + } + } + + private fun flushOpenRanges() { + val now = Clock.System.now().toEpochMilliseconds() + (longRunningStorage as? InMemoryLongRunningObservationStorage)?.flush(now) + } + + private fun setTrackingApproval(approval: Boolean) { + Napier.i { "Set app tracking approval to: $approval" } + permissionRepository.updatePermission(PermissionType.APP_TRACKING, approval) + trackingApproval = permissionRepository.getPermission(PermissionType.APP_TRACKING) + flushBufferIfPossible() + } + + private fun flushBufferIfPossible() { + if (observationIds.isNotEmpty() && scheduleIds.isNotEmpty()) { + val pendingData = dataBuffer.toList() + val remaining = mutableListOf() + val toStore = mutableListOf() + + pendingData.forEach { + val canStore = when (it) { + is BufferedData.Instant -> trackingApproval == PermissionApprovalState.GRANTED || it.data.eventType.storeWithoutApproval + is BufferedData.Range -> trackingApproval == PermissionApprovalState.GRANTED || it.storeWithoutApproval + } + if (canStore) toStore.add(it) else remaining.add(it) + } + + if (toStore.isNotEmpty()) { + dataBuffer.clear() + dataBuffer.addAll(remaining) + if (dataBuffer.isEmpty()) { + permissionRepository.removeValue(BUFFER_KEY) + } else { + persistBuffer() + } + toStore.forEach { + when (it) { + is BufferedData.Instant -> storeImmediate(it.data, it.timestamp) + is BufferedData.Range -> storeRange( + it.eventKey, + it.identifier, + it.startTimestamp, + it.endTimestamp + ) + } + } + } + } + } + + private fun storeImmediate(data: EventModel, timestamp: Long) { + val logEvent = data.eventType + val eventKey = logEvent.family?.key ?: logEvent.key + storeData( + mapOf( + DATA_KEY to mapOf( + "eventKey" to eventKey, + "identifier" to (data.eventData["identifier"] as? String ?: logEvent.key), + "timestamp" to data.timestamp + ) + ), + timestamp / 1000 + ) {} + } + + private fun storeRange( + eventKey: String, + identifier: String, + startTimestamp: Long, + endTimestamp: Long + ) { + storeData( + mapOf( + DATA_KEY to mapOf( + "eventKey" to eventKey, + "identifier" to identifier, + "startTimestamp" to startTimestamp, + "endTimestamp" to endTimestamp + ) + ), + endTimestamp / 1000 + ) {} + } + + + companion object { + private const val DATA_KEY = "USER_ACTION" + private const val BUFFER_KEY = "app_usage_data_buffer" + } +} + diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/EventModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/EventModel.kt new file mode 100644 index 000000000..ca5676890 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/EventModel.kt @@ -0,0 +1,20 @@ +/* + * 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.observations.appUsage.model + +import kotlinx.datetime.Clock + +data class EventModel( + val timestamp: Long = Clock.System.now().toEpochMilliseconds(), + val eventType: LogEvent, + val eventData: Map = emptyMap() +) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/InstantObservationPayload.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/InstantObservationPayload.kt new file mode 100644 index 000000000..bd4159e8b --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/InstantObservationPayload.kt @@ -0,0 +1,21 @@ +/* + * 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.observations.appUsage.model + +import kotlinx.serialization.Serializable + +@Serializable +data class InstantObservationPayload( + val eventKey: String, + val identifier: String, + val timestamp: Long +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt new file mode 100644 index 000000000..d0089af33 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt @@ -0,0 +1,108 @@ +/* + * 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.observations.appUsage.model + +enum class EventStorageMode { + INSTANT, + RANGE_START, + IN_RANGE, + RANGE_END +} + +enum class EventFamily(val key: String) { + APP_VISIBILITY("app_visibility"), + VIEW("view_visibility") +} + +enum class LogEvent( + val key: String, + val storageMode: EventStorageMode, + val family: EventFamily? = null, + val storeWithoutApproval: Boolean = false +) { + APP_IN_FOREGROUND( + key = "app_in_foreground", + storageMode = EventStorageMode.RANGE_START, + family = EventFamily.APP_VISIBILITY, + storeWithoutApproval = true + ), + APP_IN_BACKGROUND( + key = "app_in_background", + storageMode = EventStorageMode.RANGE_END, + family = EventFamily.APP_VISIBILITY, + storeWithoutApproval = true + ), + VIEW_OPEN( + key = "view_open", + storageMode = EventStorageMode.RANGE_START, + family = EventFamily.VIEW + ), + VIEW_CLOSED( + key = "view_closed", + storageMode = EventStorageMode.RANGE_END, + family = EventFamily.VIEW + ), + BUTTON_PRESS( + key = "button_press", + storageMode = EventStorageMode.INSTANT, + storeWithoutApproval = true + ), + NOTIFICATION_INTERACTION( + key = "notification_interaction", + storageMode = EventStorageMode.INSTANT + ), + URL_OPEN( + key = "url_open", + storageMode = EventStorageMode.INSTANT + ), + APP_TRACKING_ACCEPTED( + key = "app_tracking_accepted", + storageMode = EventStorageMode.INSTANT + ), + OBSERVATION_EVENT( + key = "observation_event", + storageMode = EventStorageMode.INSTANT + ), + APP_TRACKING_DECLINED( + key = "app_tracking_declined", + storageMode = EventStorageMode.INSTANT + ); + + fun aggregateKey(identifier: String): String = + "${family?.name ?: key}:$identifier" + + fun isRangeEvent(): Boolean = storageMode != EventStorageMode.INSTANT + + fun isRangeStart(): Boolean = storageMode == EventStorageMode.RANGE_START + + fun inRange(): Boolean = storageMode == EventStorageMode.IN_RANGE + fun isRangeEnd(): Boolean = storageMode == EventStorageMode.RANGE_END + + fun shouldStoreImmediately(): Boolean = storageMode == EventStorageMode.INSTANT + + fun matchingStartEvent(): LogEvent? = entries.firstOrNull { + it.family == family && it.storageMode == EventStorageMode.RANGE_START + } + + fun matchingEndEvent(): LogEvent? = entries.firstOrNull { + it.family == family && it.storageMode == EventStorageMode.RANGE_END + } + + fun canBeStored(openFamilies: Set): Boolean { + if (storeWithoutApproval) return true + return when (storageMode) { + EventStorageMode.INSTANT -> true + EventStorageMode.RANGE_START, EventStorageMode.IN_RANGE -> false + EventStorageMode.RANGE_END -> family != null && family in openFamilies + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/RangeObservationPayload.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/RangeObservationPayload.kt new file mode 100644 index 000000000..59655560a --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/RangeObservationPayload.kt @@ -0,0 +1,22 @@ +/* + * 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.observations.appUsage.model + +import kotlinx.serialization.Serializable + +@Serializable +data class RangeObservationPayload( + val eventKey: String, + val identifier: String, + val startTimestamp: Long, + val endTimestamp: Long +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/DefaultLongRunningObservationStorage.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/DefaultLongRunningObservationStorage.kt new file mode 100644 index 000000000..68e10809a --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/DefaultLongRunningObservationStorage.kt @@ -0,0 +1,128 @@ +/* + * 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.observations.longRunningObservation + +import io.redlink.more.database.entities.AggregatedObservationDataEntity +import io.redlink.more.database.entities.ObservationDataEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.observations.appUsage.model.LogEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +/** + * Default implementation of LongRunningObservationStorage using AggregatedObservationDataRepository. + */ +class DefaultLongRunningObservationStorage( + private val repos: MainRepository, + private val observationId: String, + private val observationType: String, + private val toDataString: (Any) -> String +) : LongRunningObservationStorage { + + private val scope = CoroutineScope(Dispatchers.IO) + + override fun storeInstant(data: T, timestamp: Long) { + scope.launch { + if (data != null) { + val observationDataEntity = ObservationDataEntity( + observationId = observationId, + observationType = observationType, + dataValue = toDataString(data), + timestamp = timestamp + ) + repos.observationData.addData(listOf(observationDataEntity)) + } + } + } + + override fun startObservation(data: T, identifier: String, timestamp: Long) { + scope.launch { + if (data != null) { + val metadata = + if (data is LogEvent) data.aggregateKey(identifier ?: "") else identifier ?: "" + val entity = AggregatedObservationDataEntity.fromData( + observationId = observationId, + observationType = observationType, + data = data, + startTimestamp = timestamp, + endTimestamp = timestamp, + metadata = metadata + ) + repos.aggregatedObservationData.insert(entity) + } + } + } + + private suspend fun getOpenObservationEntity( + data: T, + identifier: String + ): AggregatedObservationDataEntity? { + val openEntities = repos.aggregatedObservationData.getByObservationId(observationId) + if (data is LogEvent) { + val key = data.aggregateKey(identifier) + val exactMatch = openEntities.firstOrNull { it.metadata == key } + if (exactMatch != null) return exactMatch + + if (data.family != null) { + val familyPrefix = "${data.family.name}:" + return openEntities.firstOrNull { it.metadata.startsWith(familyPrefix) } + } + } + return if (openEntities.any { it.metadata == identifier }) { + openEntities.first { it.metadata == identifier } + } else { + openEntities.firstOrNull() + } + } + + override fun updateObservation( + data: T, + identifier: String, + timestamp: Long + ) { + scope.launch { + getOpenObservationEntity(data, identifier)?.let { entity -> + if (data != null) { + repos.aggregatedObservationData.update( + entity.update( + data = data, + metadata = entity.metadata, + timestamp = timestamp + ) + ) + } + } + } + } + + override fun inRangeObservation(data: T, identifier: String, timestamp: Long) { + updateObservation(data, identifier, timestamp) + } + + override fun finishObservation(data: T, identifier: String, timestamp: Long) { + scope.launch { + getOpenObservationEntity(data, identifier)?.let { entity -> + if (data != null) { + val updatedEntity = entity.update( + data = data, + metadata = entity.metadata, + timestamp = timestamp + ) + repos.observationData.addData(listOf(updatedEntity.toObservationDataEntity>>())) + repos.aggregatedObservationData.delete(entity) + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/InMemoryLongRunningObservationStorage.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/InMemoryLongRunningObservationStorage.kt new file mode 100644 index 000000000..78432d1e0 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/InMemoryLongRunningObservationStorage.kt @@ -0,0 +1,89 @@ +/* + * 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.observations.longRunningObservation + +/** + * In-memory implementation of LongRunningObservationStorage. + * Stores data into a map and provides simple lifecycle management for long-running observations. + */ +class InMemoryLongRunningObservationStorage( + private val onStoreInstant: (data: Any, timestamp: Long) -> Unit, + private val onFinish: (data: Any, identifier: String, startTimestamp: Long, endTimestamp: Long) -> Unit +) : LongRunningObservationStorage { + + private val openObservations = mutableMapOf>, Long>>() + + override fun storeInstant(data: T, timestamp: Long) { + if (data != null) { + onStoreInstant(data, timestamp) + } + } + + override fun startObservation(data: T, identifier: String, timestamp: Long) { + if (!openObservations.containsKey(identifier) && data != null) { + val list: MutableList> = mutableListOf((data as Any) to timestamp) + openObservations[identifier] = list to timestamp + } + } + + private fun findOpenKey(identifier: String): String? { + return if (openObservations.containsKey(identifier)) { + identifier + } else { + openObservations.keys.firstOrNull() + } + } + + override fun updateObservation( + data: T, + identifier: String, + timestamp: Long + ) { + findOpenKey(identifier)?.let { key -> + openObservations[key]?.let { (dataList, _) -> + if (data != null) { + dataList.add((data as Any) to timestamp) + } + } + } + } + + override fun inRangeObservation(data: T, identifier: String, timestamp: Long) { + updateObservation(data, identifier, timestamp) + } + + override fun finishObservation(data: T, identifier: String, timestamp: Long) { + val key = findOpenKey(identifier) + if (key != null) { + openObservations.remove(key)?.let { (dataList, startTimestamp) -> + if (data != null) { + dataList.add((data as Any) to timestamp) + } + onFinish(dataList, key, startTimestamp, timestamp) + } + } + } + + fun flush(timestamp: Long) { + val pending = openObservations.toMap() + openObservations.clear() + pending.forEach { (identifier, pair) -> + onFinish(pair.first, identifier, pair.second, timestamp) + } + } + + fun clear() { + openObservations.clear() + } + + fun hasOpenObservation(identifier: String): Boolean = openObservations.containsKey(identifier) +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/LongRunningObservationStorage.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/LongRunningObservationStorage.kt new file mode 100644 index 000000000..5009d4e58 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/LongRunningObservationStorage.kt @@ -0,0 +1,65 @@ +/* + * 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.observations.longRunningObservation + +/** + * Interface for managing long-running observations. + * @param T The type of the observation data or identifier. + */ +interface LongRunningObservationStorage { + /** + * Stores instant data. + * @param data The observation data. + * @param timestamp The timestamp in epoch milliseconds. + */ + fun storeInstant(data: T, timestamp: Long) + + /** + * Starts a long-running observation. + * @param data The observation data or event triggering the start. + * @param identifier A unique identifier for the observation range. + * @param timestamp The start timestamp in epoch milliseconds. + */ + fun startObservation(data: T, identifier: String, timestamp: Long) + + /** + * Updates an ongoing long-running observation. + * @param data The observation data or event. + * @param identifier A unique identifier for the observation range. + * @param timestamp The update timestamp in epoch milliseconds. + */ + fun updateObservation( + data: T, + identifier: String, + timestamp: Long + ) + + /** + * Adds an in-range event to an ongoing long-running observation. + * @param data The observation data or event. + * @param identifier A unique identifier for the observation range. + * @param timestamp The update timestamp in epoch milliseconds. + */ + fun inRangeObservation( + data: T, + identifier: String, + timestamp: Long + ) + + /** + * Finishes a long-running observation and persists the result. + * @param data The observation data or event triggering the finish. + * @param identifier A unique identifier for the observation range. + * @param timestamp The end timestamp in epoch milliseconds. + */ + fun finishObservation(data: T, identifier: String, timestamp: Long) +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AppUsageObservationType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AppUsageObservationType.kt new file mode 100644 index 000000000..cf4214c93 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AppUsageObservationType.kt @@ -0,0 +1,15 @@ +/* + * 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.observations.observationTypes + +class AppUsageObservationType : ObservationType("app-usage-observation", setOf("appTracking")) { +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepository.kt new file mode 100644 index 000000000..ac0ab7bd1 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepository.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.services.store + +interface PermissionRepository { + fun updatePermission(permissionType: PermissionType, granted: Boolean) + + fun getPermission(permissionType: PermissionType): PermissionApprovalState + + fun removePermission(permissionType: PermissionType) + + fun storeValue(key: String, value: String) + + fun loadValue(key: String): String? + + fun removeValue(key: String) +} + +enum class PermissionApprovalState { + GRANTED, DECLINED, NOT_SET +} + +enum class PermissionType(val key: String) { + APP_TRACKING("app_tracking") +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepositoryImpl.kt new file mode 100644 index 000000000..ac16784a8 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepositoryImpl.kt @@ -0,0 +1,49 @@ +/* + * 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.store + +class PermissionRepositoryImpl(private val sharedStorageRepository: SharedStorageRepository) : + PermissionRepository { + + override fun updatePermission(permissionType: PermissionType, granted: Boolean) { + sharedStorageRepository.store(permissionType.key, granted.toString()) + } + + override fun getPermission(permissionType: PermissionType): PermissionApprovalState { + val rawValue = sharedStorageRepository.load(permissionType.key, NOT_SET_STRING) + return when (rawValue) { + "true" -> PermissionApprovalState.GRANTED + "false" -> PermissionApprovalState.DECLINED + else -> PermissionApprovalState.NOT_SET + } + } + + override fun removePermission(permissionType: PermissionType) { + sharedStorageRepository.remove(permissionType.key) + } + + override fun storeValue(key: String, value: String) { + sharedStorageRepository.store(key, value) + } + + override fun loadValue(key: String): String? { + val value = sharedStorageRepository.load(key, NOT_SET_STRING) + return if (value == NOT_SET_STRING) null else value + } + + override fun removeValue(key: String) { + sharedStorageRepository.remove(key) + } + + companion object { + private const val NOT_SET_STRING = "NOT_SET" + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt deleted file mode 100644 index c5760bd92..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.redlink.more.util - -import io.github.aakira.napier.Napier - -// Object for Swift to access the Napier Logger -object KMMLogger { - fun d(tag: String? = null, message: String) { - Napier.d(message, tag = tag) - } - - fun i(tag: String? = null, message: String) { - Napier.i(message, tag = tag) - } - - fun w(tag: String? = null, message: String) { - Napier.w(message, tag = tag) - } - - fun e(tag: String? = null, message: String) { - Napier.e(message, tag = tag) - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/util/PlatformUtils.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/PlatformUtils.kt new file mode 100644 index 000000000..8aff0e3e3 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/PlatformUtils.kt @@ -0,0 +1,14 @@ +/* + * 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.util + +expect fun openSystemSettings() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt index a01307912..27c8e9dbe 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt @@ -10,7 +10,10 @@ */ package io.redlink.more.viewModels +import io.github.aakira.napier.Napier import io.ktor.utils.io.core.Closeable +import io.redlink.more.logging.event +import io.redlink.more.observations.appUsage.model.LogEvent import io.redlink.more.scopes.AppDispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -22,10 +25,22 @@ import kotlin.coroutines.CoroutineContext abstract class CoreViewModel : Closeable { protected val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - open fun viewDidAppear() {} + abstract fun viewIdentifier(): String + + open fun viewOpened() { + Napier.event(LogEvent.VIEW_OPEN, viewIdentifier()) + } + + open fun viewClosed() { + Napier.event(LogEvent.VIEW_CLOSED, viewIdentifier()) + } + + open fun viewDidAppear() { + viewOpened() + } open fun viewDidDisappear() { - close() + viewClosed() } fun launchScope( diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt index b5e06286d..c533a374f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt @@ -1,7 +1,21 @@ +/* + * 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.viewModels import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.github.aakira.napier.Napier +import io.redlink.more.logging.event +import io.redlink.more.observations.Observation +import io.redlink.more.observations.appUsage.model.LogEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -17,6 +31,7 @@ object ViewManager { private val _studyIsUpdating = MutableStateFlow(false) private val _showBluetoothView = MutableStateFlow(false) private val _showGarminConnectView = MutableStateFlow(false) + private val _showSettingsView = MutableStateFlow(false) private val _studyLoadingError = MutableStateFlow(false) private val _appInForeground = MutableStateFlow(false) private val _bleViewOpen = MutableStateFlow(false) @@ -37,6 +52,9 @@ object ViewManager { @NativeCoroutines val showGarminConnectView: StateFlow = _showGarminConnectView + @NativeCoroutines + val showSettingsView: StateFlow = _showSettingsView + @NativeCoroutines val activeStudy: StateFlow = _activeStudy @@ -114,6 +132,12 @@ object ViewManager { } fun appIsInForeground(state: Boolean) { + if (state) { + Napier.event(LogEvent.APP_IN_FOREGROUND) + Observation.resetRequestedPermissions() + } else { + Napier.event(LogEvent.APP_IN_BACKGROUND) + } _appInForeground.value = state } @@ -137,10 +161,15 @@ object ViewManager { return false } + fun showSettingsView(state: Boolean) { + _showSettingsView.value = state + } + fun resetAll() { _studyIsUpdating.value = false _showBluetoothView.value = false _showGarminConnectView.value = false + _showSettingsView.value = false _studyLoadingError.value = false _bleViewOpen.value = false pendingViewRequests.clear() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/BluetoothController.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/BluetoothController.kt index 155ea0508..2eecf559c 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/BluetoothController.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/BluetoothController.kt @@ -15,6 +15,7 @@ import io.ktor.utils.io.core.Closeable import io.redlink.more.database.entities.BluetoothDeviceEntity import io.redlink.more.database.repository.BluetoothDeviceRepository import io.redlink.more.extensions.anyNameIn +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.observations.ObservationFactory import io.redlink.more.scopes.Scope import io.redlink.more.services.bluetooth.BluetoothConnector @@ -111,6 +112,10 @@ class BluetoothController( bleManager.disableBgScanning() } + override fun viewIdentifier(): String { + return NavigationRoute.BLUETOOTH_CONNECTION.viewIdentifier + } + override fun viewDidAppear() { bleManager.uiOverrides(true) } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt index d12937988..c74e37de0 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt @@ -16,6 +16,7 @@ import io.redlink.more.extensions.set import io.redlink.more.models.DateFilter import io.redlink.more.models.DateFilterModel import io.redlink.more.models.ScheduleModel +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.scopes.Scope import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -110,4 +111,8 @@ open class CoreDashboardFilterViewModel(repository: MainRepository) : CoreViewMo currentDateFilter.transform { emit(it.mapKeys { it.key.asDataClass() }) } .asClosure(provideNewState) + override fun viewIdentifier(): String { + return NavigationRoute.OBSERVATION_FILTER.viewIdentifier + } + } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt index ca332efa5..243b6fdb1 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt @@ -1,9 +1,21 @@ +/* + * 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.viewModels.garminConnectOAuth import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.github.aakira.napier.Napier import io.ktor.http.Url import io.redlink.more.extensions.overlaps +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.services.network.NetworkService import io.redlink.more.services.store.SharedStorageRepository import io.redlink.more.viewModels.CoreViewModel @@ -73,6 +85,10 @@ class CoreGarminConnectViewModel( ViewManager.requestGarminConnectView(false) } + override fun viewIdentifier(): String { + return NavigationRoute.GARMIN_CONNECT.viewIdentifier + } + companion object { const val GARMIN_CONNECT_SUCCESSFUL_LOGIN = "GARMIN_CONNECT_SUCCESSFUL_LOGIN" diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt index f24bf9c13..1df355cf9 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt @@ -14,7 +14,10 @@ import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.github.aakira.napier.Napier import io.redlink.more.database.repository.MainRepository import io.redlink.more.extensions.set +import io.redlink.more.logging.event +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.appUsage.model.LogEvent import io.redlink.more.observations.limesurvey.LimeSurveyObservation import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -43,6 +46,9 @@ class CoreLimeSurveyViewModel( @NativeCoroutines val dataLoading: StateFlow = _dataLoading + override fun viewIdentifier(): String { + return NavigationRoute.LIMESURVEY.viewIdentifier + } init { viewModelScope.launch { @@ -83,6 +89,10 @@ class CoreLimeSurveyViewModel( fun finish() { scheduleId?.let { observation.storeData() + Napier.event( + LogEvent.OBSERVATION_EVENT, + "Limesurvey Study answered for: $limeSurveyLink" + ) observation.stopAndSetDone(it) } clear() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt index 7f6ed2e77..9450b417d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt @@ -11,6 +11,7 @@ package io.redlink.more.viewModels.login import io.redlink.more.models.LoginModel +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.registration.RegistrationService import io.redlink.more.viewModels.CoreViewModel @@ -30,4 +31,8 @@ open class CoreLoginViewModel(private val registrationService: RegistrationServi fun clearError() { registrationService.clearError() } + + override fun viewIdentifier(): String { + return NavigationRoute.LOGIN.viewIdentifier + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt index 2cdf57f98..c763f7bbf 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt @@ -15,6 +15,7 @@ import io.redlink.more.extensions.mapState import io.redlink.more.extensions.set import io.redlink.more.models.NotificationFilterTypeModel import io.redlink.more.models.NotificationModel +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -84,4 +85,8 @@ open class CoreNotificationFilterViewModel : CoreViewModel() { private fun getEnumAsList(): List { return NotificationFilterTypeModel.entries } + + override fun viewIdentifier(): String { + return NavigationRoute.NOTIFICATION_FILTER.viewIdentifier + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt index 9bd6bfc4e..3d26a1dee 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt @@ -14,6 +14,7 @@ import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.redlink.more.extensions.set import io.redlink.more.models.NotificationModel import io.redlink.more.navigation.model.DeepLinkData +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.services.notification.NotificationActionHandler import io.redlink.more.services.notification.NotificationManager import io.redlink.more.viewModels.CoreViewModel @@ -69,4 +70,8 @@ open class CoreNotificationViewModel( handler ) } + + override fun viewIdentifier(): String { + return NavigationRoute.NOTIFICATIONS.viewIdentifier + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt index 2f15b8f68..e02be5587 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt @@ -14,6 +14,7 @@ import io.ktor.utils.io.core.Closeable import io.redlink.more.database.repository.MainRepository import io.redlink.more.extensions.asClosure import io.redlink.more.models.ObservationDetailsModel +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.cancellable @@ -24,6 +25,9 @@ class CoreObservationDetailsViewModel( private val observationId: String ) : CoreViewModel() { val observationDetailsModel = MutableStateFlow(null) + override fun viewIdentifier(): String { + return NavigationRoute.OBSERVATION_DETAILS.viewIdentifier + } override fun viewDidAppear() { launchScope { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModel.kt index 30573396e..8f9f99c21 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModel.kt @@ -13,6 +13,7 @@ package io.redlink.more.viewModels.permission import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.redlink.more.extensions.mapState import io.redlink.more.models.PermissionModel +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.registration.RegistrationService import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.StateFlow @@ -26,4 +27,8 @@ class CoreConsentViewModel( registrationService.study.mapState(viewModelScope, null) { study -> study?.let { PermissionModel.create(it, studyConsentTitle) } } + + override fun viewIdentifier(): String { + return NavigationRoute.CONSENT.viewIdentifier + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt index 11e832734..41bd41e42 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt @@ -19,6 +19,7 @@ import io.redlink.more.models.DateFilterModel import io.redlink.more.models.ScheduleListType import io.redlink.more.models.ScheduleModel import io.redlink.more.models.ScheduleState +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.observations.DataRecorder import io.redlink.more.observations.Observation import io.redlink.more.observations.ObservationStates @@ -184,5 +185,14 @@ open class CoreScheduleViewModel( sortedSchedulesCache.clear() cacheVersion++ } + + override fun viewIdentifier(): String { + return when (scheduleListType) { + ScheduleListType.RUNNING -> NavigationRoute.RUNNING_SCHEDULES.viewIdentifier + ScheduleListType.COMPLETED -> NavigationRoute.COMPLETED_SCHEDULES.viewIdentifier + ScheduleListType.MANUALS -> NavigationRoute.DASHBOARD.viewIdentifier + else -> "Other Schedules" + } + } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/settings/CoreSettingsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/settings/CoreSettingsViewModel.kt index 14376c680..2e573dcf7 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/settings/CoreSettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/settings/CoreSettingsViewModel.kt @@ -11,18 +11,41 @@ package io.redlink.more.viewModels.settings import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.Shared +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.database.entities.StudyEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.getPlatform +import io.redlink.more.logging.event import io.redlink.more.models.PermissionModel +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.observationTypes.AppUsageObservationType +import io.redlink.more.services.store.PermissionApprovalState +import io.redlink.more.services.store.PermissionRepositoryImpl +import io.redlink.more.services.store.PermissionType +import io.redlink.more.services.store.SharedStorageRepository +import io.redlink.more.util.openSystemSettings import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine +interface ExitStudyListener { + fun exitStudy(onComplete: () -> Unit) +} + class CoreSettingsViewModel( - private val shared: Shared + mainRepository: MainRepository, + sharedStorageRepository: SharedStorageRepository, + private val customViewIdentifier: String? = null ) : CoreViewModel() { + private val permissionRepository = PermissionRepositoryImpl(sharedStorageRepository) private val _dataDeleted = MutableStateFlow(false) @NativeCoroutines @@ -37,25 +60,84 @@ class CoreSettingsViewModel( @NativeCoroutines val permissionModel: StateFlow = _permissionModel + private val _needsTracking = MutableStateFlow(false) + + @NativeCoroutines + val needsTracking: StateFlow = _needsTracking + + private val _allowTracking = MutableStateFlow(false) + + @NativeCoroutines + val allowTracking: StateFlow = _allowTracking + + private var exitStudyObserver: ExitStudyListener? = null + init { + _allowTracking.value = + permissionRepository.getPermission(PermissionType.APP_TRACKING) == PermissionApprovalState.GRANTED launchScope { - shared.repositories.study.study.combine(shared.repositories.observation.observations()) { study, observations -> + mainRepository.study.study.combine(mainRepository.observation.observations()) { study, observations -> Pair(study, observations) - }.cancellable().collect { - if (it.first?.active == true) { - _study.value = it.first - it.first?.let { study -> - _permissionModel.value = - PermissionModel.createFromSchema(study, it.second) - } + }.cancellable().collect { (study, observations) -> + if (study?.active == true) { + _study.value = study + _permissionModel.value = PermissionModel.createFromSchema(study, observations) + _needsTracking.value = observations.map { it.observationType } + .contains( + AppUsageObservationType().observationType + ) } } } } + fun setExitStudyObserver(observer: ExitStudyListener?) { + exitStudyObserver = observer + } + + fun setTrackingPermission(allow: Boolean) { + _allowTracking.value = allow + if (allow) { + Napier.event(LogEvent.APP_TRACKING_ACCEPTED) + } else { + Napier.event(LogEvent.APP_TRACKING_DECLINED) + } + } + fun exitStudy() { - shared.exitStudy { + exitStudyObserver?.exitStudy { _dataDeleted.value = true } } + + fun openSettings() { + openSystemSettings() + } + + // Needed for iOS + fun showAppTrackingPermissionDialog() { + val isAndroid = getPlatform().name.lowercase().contains("android") + val messageRes = if (isAndroid) { + SharedRes.strings.app_usage_tracking_disabled_message_android + } else { + SharedRes.strings.app_usage_tracking_disabled_message_ios + } + val model = AlertDialogModel( + title = StringDesc.Resource(SharedRes.strings.app_usage_tracking_disabled_title), + message = StringDesc.Resource(messageRes), + confirmLabel = StringDesc.Resource(SharedRes.strings.app_usage_tracking_disabled_confirm), + cancelLabel = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_negative_button), + onConfirm = { openSystemSettings() } + ) + AlertController.openAlertDialog(model) + } + + override fun viewIdentifier(): String { + return customViewIdentifier ?: NavigationRoute.SETTINGS.viewIdentifier + } + + override fun close() { + setExitStudyObserver(null) + super.close() + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt index 88116d84c..118f81a48 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt @@ -11,10 +11,14 @@ package io.redlink.more.viewModels.simpleQuestion import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.github.aakira.napier.Napier import io.redlink.more.database.repository.MainRepository +import io.redlink.more.logging.event import io.redlink.more.models.QuestionModel +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.observations.Observation import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.appUsage.model.LogEvent import io.redlink.more.observations.observationTypes.QuestionType import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -65,6 +69,10 @@ class QuestionCoreViewModel( fun finishQuestion(data: Any) { _questionModel.value?.let { questionModel -> + Napier.event( + LogEvent.OBSERVATION_EVENT, + "Questionnaire answered, but not yet sent, for Observation ID: $observationId" + ) observation?.let { observation -> observation.start( questionModel.observationId, @@ -72,6 +80,10 @@ class QuestionCoreViewModel( notificationId ) observation.storeData(mapOf(questionModel.type.observationDataResponseKey to data)) { + Napier.event( + LogEvent.OBSERVATION_EVENT, + "Questionnaire answer successfully sent with Observation ID: $observationId" + ) scheduleId?.let { observation.stopAndSetDone(it) } @@ -79,4 +91,8 @@ class QuestionCoreViewModel( } } } + + override fun viewIdentifier(): String { + return NavigationRoute.QUESTION.viewIdentifier + } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/startupConnection/CoreBluetoothViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/startupConnection/CoreBluetoothViewModel.kt index 31f0ccae2..260608747 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/startupConnection/CoreBluetoothViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/startupConnection/CoreBluetoothViewModel.kt @@ -12,6 +12,7 @@ package io.redlink.more.viewModels.startupConnection import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.redlink.more.database.entities.BluetoothDeviceEntity +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.observations.ObservationFactory import io.redlink.more.viewModels.CoreViewModel import io.redlink.more.viewModels.bluetoothConnection.BluetoothController @@ -23,6 +24,7 @@ class CoreBluetoothViewModel( ) : CoreViewModel() { @NativeCoroutines val devicesNeededToConnectTo: StateFlow> = observationFactory.studyObservationTypes + override fun viewIdentifier(): String = NavigationRoute.BLUETOOTH_CONNECTION.viewIdentifier override fun viewDidAppear() { coreBluetooth.viewDidAppear() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModel.kt index 22e19cf86..0d5a027be 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModel.kt @@ -13,6 +13,7 @@ package io.redlink.more.viewModels.studydetails import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.redlink.more.Shared import io.redlink.more.models.StudyDetailsModel +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,11 +21,15 @@ import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull -class CoreStudyDetailsViewModel(shared: Shared) : CoreViewModel() { +class CoreStudyDetailsViewModel(shared: Shared, private val customViewIdentifier: String? = null) : + CoreViewModel() { private val _studyModel = MutableStateFlow(null) @NativeCoroutines val studyModel: StateFlow = _studyModel + override fun viewIdentifier(): String { + return customViewIdentifier ?: NavigationRoute.STUDY_DETAILS.viewIdentifier + } init { launchScope { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt index f1409e7fa..c89085146 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt @@ -29,6 +29,9 @@ class CoreTaskCompletionBarViewModel( @NativeCoroutines val taskCompletion: StateFlow = _taskCompletion + override fun viewIdentifier(): String { + return "Task Completion Sub View" + } init { launchScope(dispatcher) { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModel.kt index 341dc1172..c02612c32 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModel.kt @@ -14,6 +14,7 @@ import io.ktor.utils.io.core.Closeable import io.redlink.more.database.repository.MainRepository import io.redlink.more.extensions.asClosure import io.redlink.more.models.TaskDetailsModel +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.observations.DataRecorder import io.redlink.more.observations.Observation import io.redlink.more.observations.ObservationStates @@ -97,4 +98,8 @@ class CoreTaskDetailsViewModel( fun pauseObservation() { dataRecorder.pause(scheduleId) } + + override fun viewIdentifier(): String { + return "${NavigationRoute.SCHEDULE_DETAILS.viewIdentifier}: ${taskDetailsModel.value}" + } } \ No newline at end of file diff --git a/shared/src/commonMain/moko-resources/base/strings.xml b/shared/src/commonMain/moko-resources/base/strings.xml index 423f36be8..c50d0bb4e 100644 --- a/shared/src/commonMain/moko-resources/base/strings.xml +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -1,7 +1,33 @@ - + + Please open the app and start the observation! app io.redlink.more + App Tracking Permission + This study uses app usage data to understand how + participants use their phones. Allowing this permission helps us collect the data required + for the study. + + Allow + Deny + App Tracking Disabled + App usage tracking is disabled. Please + enable it in the study settings. If it's already enabled there, please check your general + iOS privacy settings for "Tracking". + + App usage tracking is disabled. + Please enable it in the study settings. + + Settings diff --git a/shared/src/commonMain/moko-resources/de/strings.xml b/shared/src/commonMain/moko-resources/de/strings.xml index 1486e9a21..0ce42f33a 100644 --- a/shared/src/commonMain/moko-resources/de/strings.xml +++ b/shared/src/commonMain/moko-resources/de/strings.xml @@ -1,4 +1,33 @@ - + + Bitte öffne die App und starte die Beobachtung! + Berechtigung zur App-Nutzungsverfolgung + Diese Studie verwendet App-Nutzungsdaten, um zu + verstehen, wie + Teilnehmende ihre Smartphones verwenden. Wenn Sie diese Berechtigung erlauben, helfen Sie + uns, + die für die Studie erforderlichen Daten zu erfassen. + + Erlauben + Verweigern + App-Tracking deaktiviert + Die App-Nutzungsverfolgung ist + deaktiviert. Bitte aktivieren Sie diese in den Studieneinstellungen. Wenn sie dort bereits + aktiviert ist, überprüfen Sie bitte Ihre allgemeinen iOS-Datenschutzeinstellungen für + "Tracking". + + Die App-Nutzungsverfolgung ist + deaktiviert. Bitte aktivieren Sie diese in den Studieneinstellungen. + + Einstellungen \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt index 62acc6753..9f632913e 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt @@ -1,3 +1,14 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + package io.redlink.more.mocks import io.redlink.more.database.AppDatabase @@ -20,6 +31,7 @@ class AppDatabase_Impl : AppDatabase(), DB { override fun notificationDao() = TODO() override fun bluetoothDeviceDao() = TODO() override fun dataPointDao() = TODO() + override fun aggregatedObservationDataDao() = TODO() override fun createInvalidationTracker(): androidx.room.InvalidationTracker { return androidx.room.InvalidationTracker(this, emptyMap(), emptyMap(), "") diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt index 17c6c0b89..5664a9603 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt @@ -1,6 +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.mocks import io.ktor.utils.io.core.Closeable +import io.redlink.more.database.entities.AggregatedObservationDataEntity import io.redlink.more.database.entities.BluetoothDeviceEntity import io.redlink.more.database.entities.DataPointEntity import io.redlink.more.database.entities.NotificationEntity @@ -8,6 +20,7 @@ import io.redlink.more.database.entities.ObservationDataEntity import io.redlink.more.database.entities.ObservationEntity import io.redlink.more.database.entities.ScheduleEntity import io.redlink.more.database.entities.StudyEntity +import io.redlink.more.database.repository.AggregatedObservationDataRepository import io.redlink.more.database.repository.BluetoothDeviceRepository import io.redlink.more.database.repository.DataPointCountRepository import io.redlink.more.database.repository.MainRepository @@ -278,6 +291,12 @@ class MockScheduleRepository : ScheduleRepository { override fun firstScheduleIdAvailableForObservationId(observationId: String): Flow = firstScheduleAvailableForObservationId(observationId).map { it?.scheduleId } + override fun observationTypesForScheduleIds(scheduleIds: Set): Flow> = + _schedules.map { + it.values.filter { s -> s.scheduleId in scheduleIds }.map { s -> s.observationType } + .toSet() + } + var firstAndLastDateResult: Flow>? = null override fun getFirstAndLastDate(observationId: String): Flow> = firstAndLastDateResult ?: flowOf(null to null) @@ -382,7 +401,68 @@ class MockStudyRepository : StudyRepository { } } -class MockMainRepository : MainRepository { + +class MockAggregatedObservationDataRepository : AggregatedObservationDataRepository { + private val data = MutableStateFlow>(emptyMap()) + + override suspend fun insert(entity: AggregatedObservationDataEntity) { + data.value += (entity.id to entity) + } + + override suspend fun insertAll(entities: List) { + data.value += entities.associateBy { it.id } + } + + override suspend fun update(entity: AggregatedObservationDataEntity) { + data.value += (entity.id to entity) + } + + override suspend fun delete(entity: AggregatedObservationDataEntity) { + data.value -= entity.id + } + + override suspend fun deleteById(id: String) { + data.value -= id + } + + override suspend fun deleteByObservationId(observationId: String) { + data.value = data.value.filterValues { it.observationId != observationId } + } + + override suspend fun deleteAll() { + data.value = emptyMap() + } + + override suspend fun getById(id: String): AggregatedObservationDataEntity? = data.value[id] + + override fun getByIdFlow(id: String): Flow = + data.map { it[id] } + + override suspend fun getByObservationId(observationId: String): List = + data.value.values.filter { it.observationId == observationId } + + override fun getByObservationIdFlow(observationId: String): Flow> = + data.map { it.values.filter { e -> e.observationId == observationId } } + + override suspend fun getByObservationType(observationType: String): List = + data.value.values.filter { it.observationType == observationType } + + override fun getByObservationTypeFlow(observationType: String): Flow> = + data.map { it.values.filter { e -> e.observationType == observationType } } + + override suspend fun getAll(): List = + data.value.values.toList() + + override fun getAllFlow(): Flow> = + data.map { it.values.toList() } + + override suspend fun getCount(): Int = data.value.size + + override suspend fun getCountByObservationId(observationId: String): Int = + data.value.values.count { it.observationId == observationId } +} + +class MockMainRepository() : MainRepository { private val _mockSchedule = MockScheduleRepository() private val _mockNotification = MockNotificationRepository() private val _mockObservation = MockObservationRepository() @@ -390,6 +470,7 @@ class MockMainRepository : MainRepository { private val _mockDataPointCount = MockDataPointCountRepository() private val _mockBluetoothDevice = MockBluetoothDeviceRepository() private val _mockStudy = MockStudyRepository() + private val _mockAggregatedObservationData = MockAggregatedObservationDataRepository() override val schedule: ScheduleRepository get() = _mockSchedule override val notification: NotificationRepository get() = _mockNotification @@ -398,6 +479,7 @@ class MockMainRepository : MainRepository { override val dataPointCount: DataPointCountRepository get() = _mockDataPointCount override val bluetoothDevice: BluetoothDeviceRepository get() = _mockBluetoothDevice override val study: StudyRepository get() = _mockStudy + override val aggregatedObservationData: AggregatedObservationDataRepository get() = _mockAggregatedObservationData val mockSchedule: MockScheduleRepository get() = _mockSchedule val mockNotification: MockNotificationRepository get() = _mockNotification @@ -405,14 +487,24 @@ class MockMainRepository : MainRepository { val mockObservationData: MockObservationDataRepository get() = _mockObservationData val mockDataPointCount: MockDataPointCountRepository get() = _mockDataPointCount val mockStudy: MockStudyRepository get() = _mockStudy + val mockAggregatedObservationData: MockAggregatedObservationDataRepository get() = _mockAggregatedObservationData - override suspend fun deleteAll() {} + override suspend fun deleteAll() { + _mockStudy.deleteStudy() + _mockNotification.deleteAll() + _mockAggregatedObservationData.deleteAll() + } } class MockObservationFactory( repository: MainRepository = MockMainRepository(), + sharedStorageRepository: MockSharedStorageRepository = MockSharedStorageRepository(), dataManager: ObservationDataManager? = null -) : ObservationFactory(repository, dataManager ?: mockObservationDataManager(repository)) { +) : ObservationFactory( + repository, + sharedStorageRepository, + dataManager ?: mockObservationDataManager(repository) +) { var matchingObservationTypes: Set = emptySet() override fun getMatchingObservationTypes(types: Set): Set = diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.kt index a9c83a38d..6e81f1f2d 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.kt @@ -1,3 +1,14 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + package io.redlink.more.mocks import dev.tmapps.konnection.Konnection @@ -5,6 +16,8 @@ import io.redlink.more.database.repository.MainRepository import io.redlink.more.observations.ObservationDataManager +import io.redlink.more.services.store.SharedStorageRepository + fun mockObservationDataManager(repository: MainRepository = MockMainRepository()): ObservationDataManager { return object : ObservationDataManager(repository) { override val konnection: Konnection? = null @@ -12,3 +25,42 @@ fun mockObservationDataManager(repository: MainRepository = MockMainRepository() override fun sendData(immediately: Boolean, onCompletion: (Boolean) -> Unit) {} } } + +class MockSharedStorageRepository : SharedStorageRepository { + private val data = mutableMapOf() + + override fun store(key: String, value: String) { + data[key] = value + } + + override fun store(key: String, value: Boolean) { + data[key] = value + } + + override fun store(key: String, value: Int) { + data[key] = value + } + + override fun store(key: String, value: Float) { + data[key] = value + } + + override fun store(key: String, value: Double) { + data[key] = value + } + + override fun store(key: String, value: Long) { + data[key] = value + } + + override fun load(key: String, default: String): String = data[key] as? String ?: default + override fun load(key: String, default: Boolean): Boolean = data[key] as? Boolean ?: default + override fun load(key: String, default: Int): Int = data[key] as? Int ?: default + override fun load(key: String, default: Float): Float = data[key] as? Float ?: default + override fun load(key: String, default: Double): Double = data[key] as? Double ?: default + override fun load(key: String, default: Long): Long = data[key] as? Long ?: default + + override fun remove(key: String) { + data.remove(key) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/InMemoryLongRunningObservationStorageTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/InMemoryLongRunningObservationStorageTest.kt new file mode 100644 index 000000000..0ac2dbbe9 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/InMemoryLongRunningObservationStorageTest.kt @@ -0,0 +1,220 @@ +/* + * 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.observations + +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.longRunningObservation.InMemoryLongRunningObservationStorage +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class InMemoryLongRunningObservationStorageTest { + + @Test + fun testStoreInstant() { + var storedData: Any? = null + var storedTimestamp: Long? = null + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { data, timestamp -> + storedData = data + storedTimestamp = timestamp + }, + onFinish = { _, _, _, _ -> } + ) + + val data = "instantData" + val timestamp = 123456789L + storage.storeInstant(data, timestamp) + + assertEquals(data, storedData) + assertEquals(timestamp, storedTimestamp) + } + + @Test + fun testStartAndFinishObservation() { + var finishedData: Any? = null + var finishedIdentifier: String? = null + var finishedStartTimestamp: Long? = null + var finishedEndTimestamp: Long? = null + + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { data, identifier, startTimestamp, endTimestamp -> + finishedData = data + finishedIdentifier = identifier + finishedStartTimestamp = startTimestamp + finishedEndTimestamp = endTimestamp + } + ) + + val startData = "startData" + val identifier = "obs1" + val startTimestamp = 1000L + val endTimestamp = 2000L + + storage.startObservation(startData, identifier, startTimestamp) + assertTrue(storage.hasOpenObservation(identifier)) + + storage.finishObservation(startData, identifier, endTimestamp) + assertFalse(storage.hasOpenObservation(identifier)) + + assertTrue(finishedData is List<*>) + val finishedList = finishedData as List<*> + assertEquals(2, finishedList.size) + val first = finishedList[0] as Pair<*, *> + val second = finishedList[1] as Pair<*, *> + assertEquals(startData, first.first) + assertEquals(startTimestamp, first.second) + assertEquals(startData, second.first) + assertEquals(endTimestamp, second.second) + assertEquals(identifier, finishedIdentifier) + assertEquals(startTimestamp, finishedStartTimestamp) + assertEquals(endTimestamp, finishedEndTimestamp) + } + + @Test + fun testUpdateObservation() { + var finishedData: Any? = null + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { data, _, _, _ -> + finishedData = data + } + ) + + val startData = "startData" + val updateData = "updateData" + val identifier = "obs1" + val startTimestamp = 1000L + val updateTimestamp = 1500L + val endTimestamp = 2000L + + storage.startObservation(startData, identifier, startTimestamp) + storage.updateObservation(updateData, identifier, updateTimestamp) + storage.finishObservation(updateData, identifier, endTimestamp) + + assertTrue(finishedData is List<*>) + val finishedList = finishedData as List<*> + assertEquals(3, finishedList.size) + val first = finishedList[0] as Pair<*, *> + val second = finishedList[1] as Pair<*, *> + val third = finishedList[2] as Pair<*, *> + assertEquals(startData, first.first) + assertEquals(startTimestamp, first.second) + assertEquals(updateData, second.first) + assertEquals(updateTimestamp, second.second) + assertEquals(updateData, third.first) + assertEquals(endTimestamp, third.second) + } + + @Test + fun testFlush() { + val finishedObservations = mutableListOf() + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { _, identifier, _, _ -> + finishedObservations.add(identifier) + } + ) + + storage.startObservation("data1", "obs1", 1000L) + storage.startObservation("data2", "obs2", 1100L) + + assertTrue(storage.hasOpenObservation("obs1")) + assertTrue(storage.hasOpenObservation("obs2")) + + storage.flush(2000L) + + assertFalse(storage.hasOpenObservation("obs1")) + assertFalse(storage.hasOpenObservation("obs2")) + assertEquals(2, finishedObservations.size) + assertTrue(finishedObservations.contains("obs1")) + assertTrue(finishedObservations.contains("obs2")) + } + + @Test + fun testFinishWithoutExactIdentifier() { + var finishedIdentifier: String? = null + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { _, identifier, _, _ -> + finishedIdentifier = identifier + } + ) + + storage.startObservation("data1", "obs1", 1000L) + storage.finishObservation("data1", "somethingElse", 2000L) + + assertEquals("obs1", finishedIdentifier) + assertFalse(storage.hasOpenObservation("obs1")) + } + + @Test + fun testFinishWithNullIdentifier() { + var finishedIdentifier: String? = null + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { _, identifier, _, _ -> + finishedIdentifier = identifier + } + ) + + storage.startObservation("data1", "obs1", 1000L) + storage.finishObservation("data1", "obs1", 2000L) + + assertEquals("obs1", finishedIdentifier) + } + + @Test + fun testLogEventFamilyMatching() { + var finishedIdentifier: String? = null + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { _, identifier, _, _ -> + finishedIdentifier = identifier + } + ) + + // APP_IN_FOREGROUND and APP_IN_BACKGROUND share the same family APP_VISIBILITY + val startEvent = LogEvent.APP_IN_FOREGROUND + val endEvent = LogEvent.APP_IN_BACKGROUND + val identifier = "test_identifier" + val startTimestamp = 1000L + val endTimestamp = 2000L + + storage.startObservation(startEvent, identifier, startTimestamp) + storage.finishObservation(endEvent, identifier, endTimestamp) + + assertEquals(identifier, finishedIdentifier) + assertFalse(storage.hasOpenObservation(identifier)) + } + + @Test + fun testClear() { + var finishedCalled = false + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { _, _, _, _ -> + finishedCalled = true + } + ) + + storage.startObservation("data1", "obs1", 1000L) + assertTrue(storage.hasOpenObservation("obs1")) + + storage.clear() + + assertFalse(storage.hasOpenObservation("obs1")) + assertFalse(finishedCalled) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt new file mode 100644 index 000000000..09e6660c3 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt @@ -0,0 +1,253 @@ +/* + * 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.observations.appUsage + +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockSharedStorageRepository +import io.redlink.more.mocks.mockObservationDataManager +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.services.store.PermissionRepositoryImpl +import io.redlink.more.services.store.PermissionType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AppUsageObservationTest { + + @Test + fun testInstantEvent() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, true) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.start("1", "1") + + val event = LogEvent.URL_OPEN + observation.onEvent(event, "https://redlink.at") + + assertEquals(1, mockRepo.mockObservationData.addedData.size) + val storedData = mockRepo.mockObservationData.addedData.first() + assertTrue(storedData.dataValue.contains("url_open")) + // Check for presence of string without colons or dots that might be escaped + assertTrue(storedData.dataValue.contains("redlink")) + } + + @Test + fun testRangeEvent() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, true) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.start("1", "1") + + observation.onEvent(LogEvent.VIEW_OPEN, "test_view") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + + observation.onEvent(LogEvent.VIEW_CLOSED, "test_view") + + assertEquals(1, mockRepo.mockObservationData.addedData.size) + val storedData = mockRepo.mockObservationData.addedData.first() + assertTrue(storedData.dataValue.contains("view_visibility")) + assertTrue(storedData.dataValue.contains("test_view")) + } + + @Test + fun testTrackingDeclined() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.start("1", "1") + + observation.onEvent(LogEvent.URL_OPEN, "https://example.com") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + } + + @Test + fun testStoreWithoutApproval() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.start("1", "1") + + // BUTTON_PRESS has storeWithoutApproval = true now + observation.onEvent(LogEvent.BUTTON_PRESS, "test_button") + + assertEquals(1, mockRepo.mockObservationData.addedData.size) + val storedData = mockRepo.mockObservationData.addedData.first() + assertTrue(storedData.dataValue.contains("button_press")) + } + + @Test + fun testSendAfterApproval() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.start("1", "1") + + // VIEW_OPEN has storeWithoutApproval = false + observation.onEvent(LogEvent.VIEW_OPEN, "test_view") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + + // Accept tracking + observation.onEvent(LogEvent.APP_TRACKING_ACCEPTED, null) + + // Close view - now it should be stored because tracking is approved + observation.onEvent(LogEvent.VIEW_CLOSED, "test_view") + // It should be 2 now because APP_TRACKING_ACCEPTED is also stored! + // and it flushes the buffered VIEW_OPEN + assertEquals(2, mockRepo.mockObservationData.addedData.size) + assertTrue(mockRepo.mockObservationData.addedData.any { it.dataValue.contains("view_visibility") }) + assertTrue(mockRepo.mockObservationData.addedData.any { it.dataValue.contains("app_tracking_accepted") }) + } + + @Test + fun testPersistenceAcrossRestarts() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) + + // First session: Tracking is declined, we log an event + val observation1 = AppUsageObservation(mockRepo, permissionRepo) + observation1.setDataManager(mockObservationDataManager(mockRepo)) + observation1.start("1", "1") + + observation1.onEvent(LogEvent.URL_OPEN, "https://example.com/buffered") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + // Check if it's in shared storage + assertTrue(mockSharedStorage.load("app_usage_data_buffer", "").contains("url_open")) + + // Restart session: Tracking is still declined, we log another event + val observation2 = AppUsageObservation(mockRepo, permissionRepo) + observation2.setDataManager(mockObservationDataManager(mockRepo)) + observation2.start("1", "1") + + observation2.onEvent(LogEvent.URL_OPEN, "https://example.com/buffered2") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + assertTrue(mockSharedStorage.load("app_usage_data_buffer", "").contains("buffered")) + assertTrue(mockSharedStorage.load("app_usage_data_buffer", "").contains("buffered2")) + + // Finally accept tracking + observation2.onEvent(LogEvent.APP_TRACKING_ACCEPTED, null) + + // Both buffered events + APP_TRACKING_ACCEPTED should be stored now + assertEquals(3, mockRepo.mockObservationData.addedData.size) + val dataValues = mockRepo.mockObservationData.addedData.map { it.dataValue } + assertTrue(dataValues.any { it.contains("buffered") }) + assertTrue(dataValues.any { it.contains("buffered2") }) + assertTrue(dataValues.any { it.contains("app_tracking_accepted") }) + + // Shared storage should be cleared + assertEquals("NOT_SET", mockSharedStorage.load("app_usage_data_buffer", "NOT_SET")) + } + + @Test + fun testBufferUntilStart() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, true) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + + // No start("1", "1") yet! + + observation.onEvent(LogEvent.URL_OPEN, "https://redlink.at") + + // Should be buffered even if tracking is approved + assertEquals(0, mockRepo.mockObservationData.addedData.size) + assertTrue(mockSharedStorage.load("app_usage_data_buffer", "").contains("url_open")) + + // Now start + observation.start("1", "1") + + // Should be flushed + assertEquals(1, mockRepo.mockObservationData.addedData.size) + assertTrue(mockRepo.mockObservationData.addedData.first().dataValue.contains("url_open")) + } + + @Test + fun testStoreWithoutApprovalBufferedUntilStart() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) // Declined + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + + // BUTTON_PRESS has storeWithoutApproval = true + observation.onEvent(LogEvent.BUTTON_PRESS, "test_button") + + // Should be buffered because not started + assertEquals(0, mockRepo.mockObservationData.addedData.size) + assertTrue(mockSharedStorage.load("app_usage_data_buffer", "").contains("button_press")) + + // Now start + observation.start("1", "1") + + // Should be flushed even if tracking is NOT approved + assertEquals(1, mockRepo.mockObservationData.addedData.size) + assertTrue(mockRepo.mockObservationData.addedData.first().dataValue.contains("button_press")) + } + + @Test + fun testOnStudyExitClearsBuffer() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, true) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + + // Buffer an event (by not starting observation) + observation.onEvent(LogEvent.URL_OPEN, "https://redlink.at") + assertTrue(mockSharedStorage.load("app_usage_data_buffer", "").contains("url_open")) + + // Start an observation range + observation.onEvent(LogEvent.VIEW_OPEN, "test_view") + + // Call onStudyExit + observation.onStudyExit() + + // Buffer should be cleared from shared storage + assertEquals("NOT_SET", mockSharedStorage.load("app_usage_data_buffer", "NOT_SET")) + + // Now start the observation - nothing should be flushed because it was cleared + observation.start("1", "1") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + + // Close the view - nothing should be stored because storage was cleared and not flushed + observation.onEvent(LogEvent.VIEW_CLOSED, "test_view") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + } +} diff --git a/shared/src/iosMain/kotlin/io/redlink/more/extensions/toString.kt b/shared/src/iosMain/kotlin/io/redlink/more/extensions/toString.kt index 2b63ea041..f96815906 100644 --- a/shared/src/iosMain/kotlin/io/redlink/more/extensions/toString.kt +++ b/shared/src/iosMain/kotlin/io/redlink/more/extensions/toString.kt @@ -21,7 +21,7 @@ import platform.Foundation.create actual fun Any.asString(): String? { return try { NSJSONSerialization.dataWithJSONObject(this, NSJSONWritingPrettyPrinted, null)?.let { - return NSString.create(it, NSUTF8StringEncoding) as String? + return NSString.create(it, NSUTF8StringEncoding) as? String } } catch (e: Exception) { println(e) diff --git a/shared/src/iosMain/kotlin/io/redlink/more/util/PlatformUtils.kt b/shared/src/iosMain/kotlin/io/redlink/more/util/PlatformUtils.kt new file mode 100644 index 000000000..69f7faea0 --- /dev/null +++ b/shared/src/iosMain/kotlin/io/redlink/more/util/PlatformUtils.kt @@ -0,0 +1,28 @@ +/* + * 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.util + +import io.github.aakira.napier.Napier +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationOpenSettingsURLString + +actual fun openSystemSettings() { + Napier.d { "Opening System Settings..." } + NSURL.URLWithString(UIApplicationOpenSettingsURLString)?.let { + UIApplication.sharedApplication.openURL( + it, + options = emptyMap(), + completionHandler = null + ) + } +} From 98da0aaf67401fdf61efdb1ef74c49b60cafa603 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Wed, 18 Mar 2026 10:01:40 +0100 Subject: [PATCH 2/7] #369: Build issue fix --- shared/src/commonMain/moko-resources/base/strings.xml | 11 ----------- shared/src/commonMain/moko-resources/de/strings.xml | 11 +---------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/shared/src/commonMain/moko-resources/base/strings.xml b/shared/src/commonMain/moko-resources/base/strings.xml index c50d0bb4e..65507fab0 100644 --- a/shared/src/commonMain/moko-resources/base/strings.xml +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -1,14 +1,3 @@ - - Please open the app and start the observation! diff --git a/shared/src/commonMain/moko-resources/de/strings.xml b/shared/src/commonMain/moko-resources/de/strings.xml index 0ce42f33a..975079414 100644 --- a/shared/src/commonMain/moko-resources/de/strings.xml +++ b/shared/src/commonMain/moko-resources/de/strings.xml @@ -1,13 +1,4 @@ - + Bitte öffne die App und starte die Beobachtung! From 27ce8ebbcd0483ae7a64586c6c3e4d3be384dccc Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Thu, 19 Mar 2026 11:22:42 +0100 Subject: [PATCH 3/7] #369: Small fix --- .../more/observations/appUsage/model/LogEvent.kt | 9 ++++----- .../appUsage/AppUsageObservationTest.kt | 16 ++++++++-------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt index d0089af33..b12482097 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt @@ -33,13 +33,11 @@ enum class LogEvent( key = "app_in_foreground", storageMode = EventStorageMode.RANGE_START, family = EventFamily.APP_VISIBILITY, - storeWithoutApproval = true ), APP_IN_BACKGROUND( key = "app_in_background", storageMode = EventStorageMode.RANGE_END, family = EventFamily.APP_VISIBILITY, - storeWithoutApproval = true ), VIEW_OPEN( key = "view_open", @@ -54,7 +52,6 @@ enum class LogEvent( BUTTON_PRESS( key = "button_press", storageMode = EventStorageMode.INSTANT, - storeWithoutApproval = true ), NOTIFICATION_INTERACTION( key = "notification_interaction", @@ -66,7 +63,8 @@ enum class LogEvent( ), APP_TRACKING_ACCEPTED( key = "app_tracking_accepted", - storageMode = EventStorageMode.INSTANT + storageMode = EventStorageMode.INSTANT, + storeWithoutApproval = true ), OBSERVATION_EVENT( key = "observation_event", @@ -74,7 +72,8 @@ enum class LogEvent( ), APP_TRACKING_DECLINED( key = "app_tracking_declined", - storageMode = EventStorageMode.INSTANT + storageMode = EventStorageMode.INSTANT, + storeWithoutApproval = true ); fun aggregateKey(identifier: String): String = diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt index 09e6660c3..0e93e07a7 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt @@ -94,10 +94,12 @@ class AppUsageObservationTest { // BUTTON_PRESS has storeWithoutApproval = true now observation.onEvent(LogEvent.BUTTON_PRESS, "test_button") + observation.onEvent(LogEvent.APP_TRACKING_DECLINED, "") assertEquals(1, mockRepo.mockObservationData.addedData.size) val storedData = mockRepo.mockObservationData.addedData.first() - assertTrue(storedData.dataValue.contains("button_press")) + assertTrue(!storedData.dataValue.contains("button_press")) + assertTrue(storedData.dataValue.contains("app_tracking_declined")) } @Test @@ -204,19 +206,17 @@ class AppUsageObservationTest { val observation = AppUsageObservation(mockRepo, permissionRepo) observation.setDataManager(mockObservationDataManager(mockRepo)) - // BUTTON_PRESS has storeWithoutApproval = true - observation.onEvent(LogEvent.BUTTON_PRESS, "test_button") + observation.onEvent(LogEvent.APP_TRACKING_ACCEPTED, "") - // Should be buffered because not started assertEquals(0, mockRepo.mockObservationData.addedData.size) - assertTrue(mockSharedStorage.load("app_usage_data_buffer", "").contains("button_press")) + assertTrue( + mockSharedStorage.load("app_usage_data_buffer", "").contains("app_tracking_accepted") + ) - // Now start observation.start("1", "1") - // Should be flushed even if tracking is NOT approved assertEquals(1, mockRepo.mockObservationData.addedData.size) - assertTrue(mockRepo.mockObservationData.addedData.first().dataValue.contains("button_press")) + assertTrue(mockRepo.mockObservationData.addedData.first().dataValue.contains("app_tracking_accepted")) } @Test From b63320e129ab297c2156b319d774bb8866690a0e Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Tue, 5 May 2026 14:11:43 +0200 Subject: [PATCH 4/7] Small merging fixes --- .../dashboard/filter/DashboardFilterView.kt | 51 ++++++++++--------- .../filter/NotificationFilterView.kt | 50 +++++++++--------- iosApp/iosApp.xcodeproj/project.pbxproj | 8 +++ 3 files changed, 60 insertions(+), 49 deletions(-) diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt index 19f62c3b4..dfa344609 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt @@ -123,35 +123,36 @@ fun DashboardFilterView(coreScheduleViewModel: CoreScheduleViewModel) { MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } - items(viewModel.currentTypeFilter.entries.sortedBy { it.key }) { item -> - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if ( - item.value - ) - IconInline( - icon = Icons.Rounded.Done, - color = MoreColors.Approved, - contentDescription = getStringResource(id = R.string.more_filter_selected) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { viewModel.toggleTypeFilter(item.key) }) - .padding(4.dp) + items(viewModel.currentTypeFilter.entries.sortedBy { it.key }) { item -> + Row( + verticalAlignment = Alignment.CenterVertically, ) { - HeaderDescription( - description = getStringResourceByName(item.key.observationTypeToResource()), - color = if (item.value) MoreColors.Primary else MoreColors.Secondary + if ( + item.value ) + IconInline( + icon = Icons.Rounded.Done, + color = MoreColors.Approved, + contentDescription = getStringResource(id = R.string.more_filter_selected) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.toggleTypeFilter(item.key) }) + .padding(4.dp) + ) { + HeaderDescription( + description = getStringResourceByName(item.key.observationTypeToResource()), + color = if (item.value) MoreColors.Primary else MoreColors.Secondary + ) + } } + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterView.kt index 082584e76..6c26a87b3 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterView.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.extensions.formatNotificationFilterString import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.extensions.stringResource @@ -53,33 +54,34 @@ fun NotificationFilterView(coreViewModel: CoreNotificationFilterViewModel) { MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } - itemsIndexed(viewModel.currentFilters.entries.sortedBy { it.key.sortIndex }) { _, entry -> - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (entry.value) - IconInline( - icon = Icons.Rounded.Done, - color = MoreColors.Approved, - contentDescription = getStringResource(id = R.string.more_filter_selected) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { viewModel.toggleFilter(entry.key) }) - .padding(4.dp) + itemsIndexed(viewModel.currentFilters.entries.sortedBy { it.key.sortIndex }) { _, entry -> + Row( + verticalAlignment = Alignment.CenterVertically, ) { - HeaderDescription( - description = entry.key.toString().formatNotificationFilterString(), - color = MoreColors.Secondary - ) + if (entry.value) + IconInline( + icon = Icons.Rounded.Done, + color = MoreColors.Approved, + contentDescription = getStringResource(id = R.string.more_filter_selected) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.toggleFilter(entry.key) }) + .padding(4.dp) + ) { + HeaderDescription( + description = entry.key.toString().formatNotificationFilterString(), + color = MoreColors.Secondary + ) + } } + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } } } \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index c379dd790..b2898f648 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -556,6 +556,13 @@ path = GarminConnect; sourceTree = ""; }; + 1F60BFC92FA8A423007CD41A /* PC_Components */ = { + isa = PBXGroup; + children = ( + ); + path = PC_Components; + sourceTree = ""; + }; 1F6A4E3A29F6C94B00F0247F /* Bluetooth */ = { isa = PBXGroup; children = ( @@ -709,6 +716,7 @@ 1F9C3E8B298AAC0000B9AC82 /* Views */ = { isa = PBXGroup; children = ( + 1F60BFC92FA8A423007CD41A /* PC_Components */, 1F43DE1F2EC6136700B6F07B /* GarminConnect */, 1F1E45CF2E7842E300C82016 /* Registration */, 1F8937892BFF1EB20083D20E /* ObservationErrors */, From 091faa7bf296ee1b51762d6dbeb4815f15c0cc3d Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Wed, 6 May 2026 13:51:13 +0200 Subject: [PATCH 5/7] #369: Multiple bug fixes: - GPS recording on iOS and Android - Permission request now fully works - Notification deeplinks no longer infinitley route to the notificationview when a notification is clicked without deeplink - Translation Strings added - Cleanup --- androidApp/src/main/AndroidManifest.xml | 2 +- .../activities/consent/ConsentViewModel.kt | 8 +- .../consent/composables/ConsentButtons.kt | 10 +- .../notification/NotificationViewModel.kt | 9 +- .../extensions/ComposableExtensions.kt | 6 +- .../AndroidObservationPermissionObserver.kt | 1 + .../observations/GPS/GPSObservation.kt | 70 ++---- .../android/observations/GPS/GPSService.kt | 5 +- .../HR/PolarHeartRateObservation.kt | 5 - .../observations/ObservationExtension.kt | 8 +- .../ObservationManagerExtension.kt | 7 +- .../ObservationServiceExtension.kt | 7 +- .../android/observations/PermissionUtils.kt | 36 ++- .../accelerometer/AccelerometerObservation.kt | 15 +- .../services/ObservationRecordingService.kt | 93 +++++++- .../res/values-de/alert-dialog-strings.xml | 1 + .../src/main/res/values-de/error-strings.xml | 1 + .../values-de/observation-type-strings.xml | 2 + .../main/res/values/alert-dialog-strings.xml | 1 + .../src/main/res/values/error-strings.xml | 1 + .../res/values/observation-type-strings.xml | 2 + iosApp/iosApp/Info.plist | 208 +++++++++--------- .../iosApp/Observations/GPSObservation.swift | 111 +++++----- .../IOSObservationPermissionObserver.swift | 4 + .../entities/ObservationDataEntity.kt | 6 +- .../redlink/more/observations/Observation.kt | 85 ++++--- .../more/observations/ObservationManager.kt | 2 +- .../appUsage/AppUsageObservation.kt | 39 +--- .../entities/ObservationDataEntityTest.kt | 39 ++++ 29 files changed, 447 insertions(+), 337 deletions(-) create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/database/entities/ObservationDataEntityTest.kt diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index b29ccc59a..02f420b88 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -105,7 +105,7 @@ android:name="io.redlink.more.app.android.services.ObservationRecordingService" android:enabled="true" android:exported="false" - android:foregroundServiceType="dataSync" /> + android:foregroundServiceType="dataSync|location|connectedDevice" /> = emptyList()) { + var message = stringResource(R.string.required_permission_not_granted_message) + if (missingPermissions.isNotEmpty()) { + message += "\n\n" + stringResource(R.string.missing_permissions_label) + ": " + missingPermissions.joinToString(", ") + } AlertController.openAlertDialog( AlertDialogModel( title = StringDesc.Raw(stringResource(R.string.required_permissions_not_granted_title)), - message = StringDesc.Raw(stringResource(R.string.required_permission_not_granted_message)), + message = StringDesc.Raw(message), confirmLabel = StringDesc.Raw(stringResource(R.string.proceed_to_settings_button)), cancelLabel = StringDesc.Raw(stringResource(R.string.proceed_without_granting_button)), onConfirm = { 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 d47a4ae0c..61f8ff4c4 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 @@ -67,10 +67,12 @@ fun ConsentButtons(model: ConsentViewModel) { } } - val anyPermissionDenied = mutablePermissionMap.values.any { !it } - - if (anyPermissionDenied) { - model.openPermissionDeniedAlertDialog(context) + val deniedPermissions = mutablePermissionMap.filter { !it.value }.keys + if (deniedPermissions.isNotEmpty()) { + model.openPermissionDeniedAlertDialog( + context, + deniedPermissions.map { PermissionUtils.getPermissionLabel(context, it) } + ) } else { model.acceptConsent(context) } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationViewModel.kt index be7b356de..caf5188e7 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationViewModel.kt @@ -33,7 +33,14 @@ class NotificationViewModel(private val coreFilterViewModel: CoreNotificationFil coreViewModel.handleNotificationAction(notification) { actionType, data -> data?.let { when (actionType) { - NotificationActionHandler.DEEPLINK -> navController.navigate(data.route.toUri()) + NotificationActionHandler.DEEPLINK -> { + val uri = data.route.toUri() + val destRoute = uri.path?.removePrefix("/") ?: uri.toString().substringBefore("?") + val currentRoute = navController.currentDestination?.route?.substringBefore("?") + if (destRoute != currentRoute) { + navController.navigate(uri) + } + } else -> {} } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/extensions/ComposableExtensions.kt b/androidApp/src/main/java/io/redlink/more/app/android/extensions/ComposableExtensions.kt index 805696e29..b95f064f7 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/extensions/ComposableExtensions.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/extensions/ComposableExtensions.kt @@ -43,7 +43,11 @@ fun getStringResourceByName(name: String): String { "string", LocalContext.current.packageName ) - return getStringResource(resourceId) + return if (resourceId != 0) { + getStringResource(resourceId) + } else { + name + } } @Composable 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 639b3ca3b..dbf33ba70 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 @@ -48,6 +48,7 @@ class AndroidObservationPermissionObserver( } override fun requestPermission(observationType: ObservationType) { + Napier.d { "Requesting permissions for $observationType" } if (observationType.observationType == AppUsageObservationType().observationType) { if (permissionState(observationType) != PermissionApprovalState.NOT_SET) { return diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSObservation.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSObservation.kt index 5fc642125..f26eb3a93 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSObservation.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSObservation.kt @@ -12,37 +12,22 @@ package io.redlink.more.app.android.observations.GPS import android.Manifest import android.content.Context -import android.content.pm.PackageManager import android.location.LocationManager -import android.os.Build import android.util.Log -import androidx.core.app.ActivityCompat import com.google.android.gms.location.LocationResult import io.github.aakira.napier.Napier -import io.redlink.more.app.android.observations.showPermissionAlertDialog 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.observationTypes.GPSType +import io.redlink.more.scopes.Scope import io.redlink.more.services.store.PermissionApprovalState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch private const val TAG = "GPSObservation" -private val permissions = if (Build.VERSION.SDK_INT >= 34) { - setOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.FOREGROUND_SERVICE_LOCATION - ) -} else { - setOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION - ) -} +private val permissions = setOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION +) class GPSObservation( context: Context, @@ -51,26 +36,15 @@ class GPSObservation( ) : Observation(repos, observationType = GPSType(permissions)), GPSListener { private val locationManager = context.getSystemService(LocationManager::class.java) - private val scope = CoroutineScope(Job() + Dispatchers.IO) - - fun getPermission(): Set = permissions override fun start(): Boolean { - Napier.d { "Trying to start GPS..." } - if (this.hasPermission() == PermissionApprovalState.GRANTED) { - val listener = this - scope.launch { - Napier.d { "Registering GPS Service..." } - gpsService.registerForLocationUpdates(listener) - } - return true - } else { - this.requestPermission() - } - return false + Napier.d { "Registering GPS Service..." } + gpsService.registerForLocationUpdates(this) + return true } override fun stop(onCompletion: () -> Unit) { + Napier.d { "Unregistering GPS Service..." } this.gpsService.unregisterForLocationUpdates(this) onCompletion() } @@ -85,10 +59,6 @@ class GPSObservation( } if (this.hasPermission() != PermissionApprovalState.GRANTED) { errors.add("location_permission_not_granted") - if (!isPermissionRequested(observationType.observationType)) { - markPermissionRequested(observationType.observationType) - showPermissionAlertDialog() - } } return errors } @@ -96,7 +66,7 @@ class GPSObservation( override fun applyObservationConfig(settings: Map) { try { settings[LOCATION_INTERVAL_MILLIS_KEY]?.toString()?.trim('\"')?.toLong()?.let { - //gpsService.setIntervalMillis(it) + gpsService.setIntervalMillis(it) } } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) @@ -117,25 +87,11 @@ class GPSObservation( override fun locationAvailable(available: Boolean) { Napier.d { "Location available: $available" } - } - - private fun checkPermission(): Boolean { - return this.hasPermission() == PermissionApprovalState.GRANTED - } - - private fun hasPermissions(context: Context): Boolean { - getPermission().forEach { permission -> - if (ActivityCompat.checkSelfPermission( - context, - permission - ) == PackageManager.PERMISSION_DENIED - ) { - Napier.d { "Has no GPS permissions!" } - return false + if (!available) { + Scope.launch() { + updateObservationErrors() } } - Napier.d { "Has GPS permissions!" } - return true } companion object { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSService.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSService.kt index 173feba18..ebbcf3e57 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSService.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSService.kt @@ -30,7 +30,7 @@ class GPSService(context: Context) { LocationServices.getFusedLocationProviderClient(context) private val locationRequest = - LocationRequest.Builder(Priority.PRIORITY_BALANCED_POWER_ACCURACY, 1000) + LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000) private val locationCallback = object : LocationCallback() { override fun onLocationResult(result: LocationResult) { super.onLocationResult(result) @@ -47,11 +47,8 @@ class GPSService(context: Context) { init { setWaitForAccurateLocation(false) -// setDurationMillis(1000) setMinUpdateIntervalMillis(500) - setMaxUpdateAgeMillis(1000) setGranularity(Granularity.GRANULARITY_FINE) -// setMinUpdateDistanceMeters(10f) } fun setPriority(priority: Int) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt index 38eaf3d7e..73eac2e20 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt @@ -21,7 +21,6 @@ import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.app.android.observations.pauseObservation -import io.redlink.more.app.android.observations.showPermissionAlertDialog import io.redlink.more.app.android.services.sensorsListener.BluetoothStateListener import io.redlink.more.database.repository.MainRepository import io.redlink.more.extensions.anyNameIn @@ -149,10 +148,6 @@ class PolarHeartRateObservation(repos: MainRepository) : val errors = mutableSetOf() if (!hasPermissions(MoreApplication.appContext!!)) { errors.add("error_access_bluetooth") - if (!isPermissionRequested(observationType.observationType)) { - markPermissionRequested(observationType.observationType) - showPermissionAlertDialog() - } PolarStates.hrFeatureReady(false) } if (!BluetoothStateListener.bluetoothEnabled.value) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationExtension.kt index f91ea1bee..3c47d568f 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationExtension.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationExtension.kt @@ -21,11 +21,15 @@ import io.redlink.more.dialog.AlertDialogModel import io.redlink.more.observations.Observation import io.redlink.more.observations.observationTypes.ObservationType -fun Observation.showPermissionAlertDialog() { +fun Observation.showPermissionAlertDialog(missingPermissions: List = emptyList()) { + var message = stringResource(R.string.required_permission_not_granted_message) + if (missingPermissions.isNotEmpty()) { + message += "\n\n" + stringResource(R.string.missing_permissions_label) + ": " + missingPermissions.joinToString(", ") + } AlertController.openAlertDialog( AlertDialogModel( title = StringDesc.Raw(stringResource(R.string.required_permissions_not_granted_title)), - message = StringDesc.Raw(stringResource(R.string.required_permission_not_granted_message)), + message = StringDesc.Raw(message), confirmLabel = StringDesc.Raw(stringResource(R.string.proceed_to_settings_button)), cancelLabel = StringDesc.Raw(stringResource(R.string.proceed_without_granting_button)), onConfirm = { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationManagerExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationManagerExtension.kt index 361b0e324..137a96354 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationManagerExtension.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationManagerExtension.kt @@ -50,7 +50,12 @@ suspend fun ObservationManager.startWithPermissionCheck( Napier.d("Observation started with result: $result") } } else { - observation.showPermissionAlertDialog() + observation.showPermissionAlertDialog( + PermissionUtils.getMissingPermissionNames( + observation, + activity + ) + ) } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationServiceExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationServiceExtension.kt index d93789cb2..5671f4518 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationServiceExtension.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationServiceExtension.kt @@ -51,7 +51,12 @@ fun startObservationsWithPermissionCheck( if (granted) { startObservationsWithPermissionCheck(scheduleIds, activity) } else { - observationNeedingPermissions.showPermissionAlertDialog() + observationNeedingPermissions.showPermissionAlertDialog( + PermissionUtils.getMissingPermissionNames( + observationNeedingPermissions, + activity + ) + ) } } } else { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/PermissionUtils.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/PermissionUtils.kt index c81bbbe55..7e39963a3 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/PermissionUtils.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/PermissionUtils.kt @@ -20,6 +20,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import io.github.aakira.napier.Napier import io.redlink.more.observations.Observation +import io.redlink.more.observations.observationTypes.AppUsageObservationType +import io.redlink.more.services.store.PermissionApprovalState /** * Utility class for handling permissions for observations @@ -74,8 +76,36 @@ object PermissionUtils { * @return True if all permissions are granted, false otherwise */ fun hasAllPermissions(observation: Observation, context: Context): Boolean { - val permissions = observation.observationType.sensorPermissions - return hasAllPermissions(permissions, context) + return observation.hasPermission() == PermissionApprovalState.GRANTED + } + + fun getMissingPermissionNames(observation: Observation, context: Context): List { + val missing = mutableListOf() + if (observation.observationType.observationType == AppUsageObservationType().observationType) { + if (observation.hasPermission() != PermissionApprovalState.GRANTED) { + missing.add("App Usage") + } + } else { + for (permission in observation.observationType.sensorPermissions) { + if (ContextCompat.checkSelfPermission( + context, + permission + ) != PackageManager.PERMISSION_GRANTED + ) { + missing.add(getPermissionLabel(context, permission)) + } + } + } + return missing + } + + fun getPermissionLabel(context: Context, permission: String): String { + return try { + val permissionInfo = context.packageManager.getPermissionInfo(permission, 0) + permissionInfo.loadLabel(context.packageManager).toString() + } catch (e: Exception) { + permission.substringAfterLast('.') + } } /** @@ -141,7 +171,7 @@ object PermissionUtils { if (permissionLauncher != null) { permissionLauncher.launch(permissionsToRequest) } else { - observation.showPermissionAlertDialog() + observation.showPermissionAlertDialog(getMissingPermissionNames(observation, activity)) callback?.invoke(false) } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/accelerometer/AccelerometerObservation.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/accelerometer/AccelerometerObservation.kt index 4fede2b4a..490725ba7 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/accelerometer/AccelerometerObservation.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/accelerometer/AccelerometerObservation.kt @@ -10,13 +10,11 @@ */ package io.redlink.more.app.android.observations.accelerometer -import android.Manifest import android.content.Context import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager -import android.os.Build import android.util.Log import io.redlink.more.database.repository.MainRepository import io.redlink.more.observations.Observation @@ -31,18 +29,7 @@ class AccelerometerObservation( ) : Observation( repos, observationType = AccelerometerType( - if (Build.VERSION.SDK_INT >= 34) { - setOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.FOREGROUND_SERVICE_LOCATION - ) - } else { - setOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION - ) - } + emptySet() ) ), SensorEventListener { private val sensorManager = context.getSystemService(SensorManager::class.java) diff --git a/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt index 6de20b8a6..ab8847712 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt @@ -10,6 +10,7 @@ */ package io.redlink.more.app.android.services +import android.Manifest import android.app.Activity import android.app.AlarmManager import android.app.Notification @@ -18,10 +19,14 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.SystemClock +import androidx.core.content.ContextCompat import io.github.aakira.napier.Napier import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R @@ -56,18 +61,27 @@ class ObservationRecordingService : Service() { // Critical: Start foreground service immediately to prevent ANR crashes // This must happen within 5 seconds when startForegroundService() is called try { - if (!running) { - startForegroundService() - } + startForegroundService() } catch (e: Exception) { Napier.e("Failed to start foreground service: ${e.message}") try { - val basicNotification = Notification.Builder(this, "default") - .setContentTitle("${MoreApplication.appName} Observation Service") + val fallbackChannelId = "observation_service_fallback" + val notificationManager = getSystemService(NotificationManager::class.java) + if (notificationManager?.getNotificationChannel(fallbackChannelId) == null) { + val fallbackChannel = NotificationChannel( + fallbackChannelId, + "Observation Service Fallback", + NotificationManager.IMPORTANCE_LOW + ) + notificationManager?.createNotificationChannel(fallbackChannel) + } + val basicNotification = Notification.Builder(this, fallbackChannelId) + .setContentTitle("${MoreApplication.appName ?: "More"} Observation Service") .setContentText("Service is running") .setSmallIcon(android.R.drawable.ic_dialog_info) .build() - startForeground(1001, basicNotification) + val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + startForeground(1001, basicNotification, type) running = true } catch (fallbackException: Exception) { Napier.e("Failed to start foreground service with fallback notification: ${fallbackException.message}") @@ -419,6 +433,47 @@ class ObservationRecordingService : Service() { } } + private fun calculateForegroundServiceType(): Int { + var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + + // Location check + val hasLocationPermission = ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + if (hasLocationPermission) { + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + } + + // Connected Device check (Bluetooth) + val hasBluetoothPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.BLUETOOTH_CONNECT + ) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.BLUETOOTH_SCAN + ) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.BLUETOOTH_ADVERTISE + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + + if (hasBluetoothPermission) { + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + } + + return type + } + private fun startForegroundService() { Napier.d { "Starting the foreground service..." } try { @@ -435,11 +490,12 @@ class ObservationRecordingService : Service() { } val notification = buildNotification( - channelId = MoreApplication.DEFAULT_CHANNEL_ID!!, + channelId = MoreApplication.DEFAULT_CHANNEL_ID ?: (packageName + ".observation_service"), notificationTitle = notificationTitle, notificationText = notificationText ) - startForeground(1001, notification) + val type = calculateForegroundServiceType() + startForeground(1001, notification, type) running = true Napier.d { "Foreground service started successfully" } } catch (e: Exception) { @@ -487,8 +543,18 @@ class ObservationRecordingService : Service() { .build() } catch (e: Exception) { Napier.e("Failed to build notification: ${e.message}") - return Notification.Builder(applicationContext, "default") - .setContentTitle("${MoreApplication.appName} Service") + val fallbackChannelId = "observation_service_fallback" + val notificationManager = applicationContext.getSystemService(NotificationManager::class.java) + if (notificationManager?.getNotificationChannel(fallbackChannelId) == null) { + val fallbackChannel = NotificationChannel( + fallbackChannelId, + "Observation Service Fallback", + NotificationManager.IMPORTANCE_LOW + ) + notificationManager?.createNotificationChannel(fallbackChannel) + } + return Notification.Builder(applicationContext, fallbackChannelId) + .setContentTitle("${MoreApplication.appName ?: "More"} Service") .setContentText("Running") .setSmallIcon(android.R.drawable.ic_dialog_info) .setOngoing(true) @@ -576,7 +642,12 @@ class ObservationRecordingService : Service() { if (granted) { checkPermissionsAndStart(scheduleIds, activity) } else { - observationNeedingPermissions.showPermissionAlertDialog() + observationNeedingPermissions.showPermissionAlertDialog( + PermissionUtils.getMissingPermissionNames( + observationNeedingPermissions, + activity + ) + ) pendingScheduleIds.removeAll(scheduleIds) } } diff --git a/androidApp/src/main/res/values-de/alert-dialog-strings.xml b/androidApp/src/main/res/values-de/alert-dialog-strings.xml index 6a69ddd79..579ea2b1c 100644 --- a/androidApp/src/main/res/values-de/alert-dialog-strings.xml +++ b/androidApp/src/main/res/values-de/alert-dialog-strings.xml @@ -6,6 +6,7 @@ Wir bitten um Erlaubnis, Ihnen Push-Benachrichtigungen zu senden. Dies hilft, den aktuellen Status der Studie jederzeit aufrechtzuerhalten und dient als Erinnerung an Ihre Aufgaben. Zu den Einstellungen gehen Ohne Berechtigungen fortfahren + Fehlende Berechtigungen Keine Internetverbindung Bitte verbinden Sie das Gerät mit dem Internet und versuchen es erneut! Annehmen diff --git a/androidApp/src/main/res/values-de/error-strings.xml b/androidApp/src/main/res/values-de/error-strings.xml index cb280befb..7328032ef 100644 --- a/androidApp/src/main/res/values-de/error-strings.xml +++ b/androidApp/src/main/res/values-de/error-strings.xml @@ -14,4 +14,5 @@ Fehler beim Fortsetzen der Beobachtung! Es gab ein Verbindungsproblem mit einem Bluetooth-Sensor. Bitte stellen Sie sicher, dass Bluetooth aktiviert ist und alle notwendigen Geräte verbunden sind! Funktion zur Messung der Herzfrequenz nicht verfügbar Es konnte Garmin Connect nicht aufgerufen werden. Bitte versuchen Sie es später erneut! + Kein verfügbares Gerät verbunden \ No newline at end of file diff --git a/androidApp/src/main/res/values-de/observation-type-strings.xml b/androidApp/src/main/res/values-de/observation-type-strings.xml index ec81ad88a..73abff9b1 100644 --- a/androidApp/src/main/res/values-de/observation-type-strings.xml +++ b/androidApp/src/main/res/values-de/observation-type-strings.xml @@ -7,4 +7,6 @@ Accelerometer App Nutzung Polar Verity + Garmin + LimeSurvey \ No newline at end of file diff --git a/androidApp/src/main/res/values/alert-dialog-strings.xml b/androidApp/src/main/res/values/alert-dialog-strings.xml index ea09ebbcf..2bbb54fae 100644 --- a/androidApp/src/main/res/values/alert-dialog-strings.xml +++ b/androidApp/src/main/res/values/alert-dialog-strings.xml @@ -6,6 +6,7 @@ We request permission to send you push notifications. This assists in maintaining the study\'s current status at all times and serves as a reminder for your tasks. Proceed to Settings Proceed Without Granting Permissions + Missing permissions No internet connection Please connect to the internet and try again Accept diff --git a/androidApp/src/main/res/values/error-strings.xml b/androidApp/src/main/res/values/error-strings.xml index 5b258725a..bf8a4e0e8 100644 --- a/androidApp/src/main/res/values/error-strings.xml +++ b/androidApp/src/main/res/values/error-strings.xml @@ -14,4 +14,5 @@ Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices! Heart-rate measurement feature unavailable Cannot connect to Garmin Connect. Please try again later! + No viable device connected \ No newline at end of file diff --git a/androidApp/src/main/res/values/observation-type-strings.xml b/androidApp/src/main/res/values/observation-type-strings.xml index 02d43f38a..855579091 100644 --- a/androidApp/src/main/res/values/observation-type-strings.xml +++ b/androidApp/src/main/res/values/observation-type-strings.xml @@ -7,4 +7,6 @@ Accelerometer App Usage Polar Verity + Garmin + LimeSurvey \ No newline at end of file diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 6d46849c9..5b0910403 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -1,120 +1,120 @@ - + + - - BGTaskSchedulerPermittedIdentifiers - - $(PRODUCT_BUNDLE_IDENTIFIER).data-upload - $(PRODUCT_BUNDLE_IDENTIFIER).dailyRefresh - $(PRODUCT_BUNDLE_IDENTIFIER).observation-reminder-refresh - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0.0 - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLIconFile - more_logo_hf2 - CFBundleURLName - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleURLSchemes - - umm-app - - - - CFBundleVersion - 1.0.0 - FirebaseAppDelegateProxyEnabled - - ITSAppUsesNonExemptEncryption - - LSRequiresIPhoneOS - - NSBluetoothAlwaysUsageDescription - $(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health + + BGTaskSchedulerPermittedIdentifiers + + $(PRODUCT_BUNDLE_IDENTIFIER).data-upload + $(PRODUCT_BUNDLE_IDENTIFIER).dailyRefresh + $(PRODUCT_BUNDLE_IDENTIFIER).observation-reminder-refresh + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0.0 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLIconFile + more_logo_hf2 + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleURLSchemes + + umm-app + + + + CFBundleVersion + 1.0.0 + FirebaseAppDelegateProxyEnabled + + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + NSBluetoothAlwaysUsageDescription + $(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health devices - NSBluetoothPeripheralUsageDescription - $(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health + NSBluetoothPeripheralUsageDescription + $(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health devices - NSCameraUsageDescription - $(PRODUCT_NAME) needs your camera to scan QR-Code - NSLocationAlwaysAndWhenInUseUsageDescription - $(PRODUCT_NAME) needs to access you location to track your position for certain + NSCameraUsageDescription + $(PRODUCT_NAME) needs your camera to scan QR-Code + NSLocationAlwaysAndWhenInUseUsageDescription + $(PRODUCT_NAME) needs to access you location to track your position for certain studies - NSLocationAlwaysUsageDescription - $(PRODUCT_NAME) always needs access to you location to track your position for + NSLocationAlwaysUsageDescription + $(PRODUCT_NAME) always needs access to you location to track your position for certain studies - NSLocationUsageDescription - $(PRODUCT_NAME) needs to access you location to track your position for certain + NSLocationUsageDescription + $(PRODUCT_NAME) needs to access you location to track your position for certain studies - NSLocationWhenInUseUsageDescription - $(PRODUCT_NAME) needs access to your location to track your position for certain + NSLocationWhenInUseUsageDescription + $(PRODUCT_NAME) needs access to your location to track your position for certain studies - NSMotionUsageDescription - $(PRODUCT_NAME) needs access to your motion data to record your movement data in the + NSMotionUsageDescription + $(PRODUCT_NAME) needs access to your motion data to record your movement data in the background - NSUserTrackingUsageDescription - $(PRODUCT_NAME) wants to track your app usage for your study - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - - UIBackgroundModes - - bluetooth-central - fetch - location - processing - remote-notification - - UILaunchStoryboardName - Launch Screen.storyboard - UIRequiredDeviceCapabilities - - armv7 - accelerometer - gyroscope - gps - bluetooth-le - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - CFBundleLocalizations - - en - de - - + NSUserTrackingUsageDescription + $(PRODUCT_NAME) wants to track your app usage for your study + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + bluetooth-central + fetch + location + processing + remote-notification + + UILaunchStoryboardName + Launch Screen.storyboard + UIRequiredDeviceCapabilities + + armv7 + accelerometer + gyroscope + gps + bluetooth-le + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + CFBundleLocalizations + + en + de + + diff --git a/iosApp/iosApp/Observations/GPSObservation.swift b/iosApp/iosApp/Observations/GPSObservation.swift index 462d222c3..92943281e 100644 --- a/iosApp/iosApp/Observations/GPSObservation.swift +++ b/iosApp/iosApp/Observations/GPSObservation.swift @@ -19,57 +19,45 @@ import UIKit import shared class GPSObservation: Observation_ { - private let manager: CLLocationManager = CLLocationManager() - private let locationDelegateProxy: LocationDelegateProxy + private var manager: CLLocationManager? public var currentLocation = CLLocation() init(repos: MainRepository, sensorPermissions: Set) { - self.locationDelegateProxy = LocationDelegateProxy(owner: nil) super.init(repos: repos, observationType: GPSType(sensorPermissions: sensorPermissions)) - - self.locationDelegateProxy.owner = self - - if Thread.isMainThread { - manager.delegate = locationDelegateProxy - } else { - DispatchQueue.main.async { [weak self] in - guard let self else { - return - } - self.manager.delegate = self.locationDelegateProxy - } + Task { @MainActor [weak self] in + self?.manager = CLLocationManager() + self?.manager?.delegate = self + + self?.manager?.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + self?.manager?.activityType = .fitness } - manager.desiredAccuracy = kCLLocationAccuracyBest } + override func start() -> Bool { - if hasPermission() == .granted { - DispatchQueue.main.async { - self.manager.allowsBackgroundLocationUpdates = true - self.manager.showsBackgroundLocationIndicator = true - self.manager.startUpdatingLocation() + Task { @MainActor [weak self] in + if let manager = self?.manager { + manager.allowsBackgroundLocationUpdates = true + manager.showsBackgroundLocationIndicator = true + manager.startUpdatingLocation() + Napier.d("Started GPS location updates") } - return true } - return false + + return true } override func stop(onCompletion: @escaping () -> Void) { - manager.stopUpdatingLocation() + Napier.d("Stopping GPS location updates") + Task { @MainActor [weak self] in + self?.manager?.stopUpdatingLocation() + } onCompletion() } override func observerErrors() -> Set { var errors: Set = [] - if manager.authorizationStatus == .notDetermined { - errors.insert("Permission request pending until observation is about to start") - manager.requestWhenInUseAuthorization() - } else if manager.authorizationStatus != .authorizedWhenInUse - && manager.authorizationStatus != .authorizedAlways - { - errors.insert("Permission not granted to access location of the device") - PermissionManager.openSensorPermissionDialog() - } else if !CLLocationManager.locationServicesEnabled() { + if !CLLocationManager.locationServicesEnabled() { errors.insert("Location Services not enabled") } return errors @@ -77,10 +65,37 @@ class GPSObservation: Observation_ { override func applyObservationConfig(settings: [String: Any]) { } + } -extension GPSObservation { - fileprivate func handleLocations(_ locations: [CLLocation]) { +extension GPSObservation: CLLocationManagerDelegate { + func handleLocations(_ locations: [CLLocation]) { + self.storeLocations(locations) + } + + func handleAuthorizationChange(_ manager: CLLocationManager) { + if manager.authorizationStatus == .restricted || manager.authorizationStatus == .denied || manager.accuracyAuthorization != .fullAccuracy { + super.stopAndSetState(state: .paused, scheduleId: nil) + } + } + + @objc func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + self.storeLocations(locations) + } + + @objc func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Napier.e("Location update error \(error)") + } + + @objc func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + self.handleAuthorizationChange(manager) + } + + @objc func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + self.handleAuthorizationChange(manager) + } + + private func storeLocations(_ locations: [CLLocation]) { let data = locations.compactMap { location in let dict = [ "longitude": location.coordinate.longitude, @@ -91,30 +106,6 @@ extension GPSObservation { } storeData(data: data) {} } + - fileprivate func handleAuthorizationChange(_ manager: CLLocationManager) { - if manager.authorizationStatus == .restricted || manager.authorizationStatus == .denied || manager.accuracyAuthorization != .fullAccuracy { - super.stopAndSetState(state: .active, scheduleId: nil) - } - } -} - -private final class LocationDelegateProxy: NSObject, CLLocationManagerDelegate { - weak var owner: GPSObservation? - - init(owner: GPSObservation?) { - self.owner = owner - } - - func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - owner?.handleLocations(locations) - } - - func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - owner?.handleAuthorizationChange(manager) - } - - func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - print("Location error:", error) - } } diff --git a/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift b/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift index 902f942b0..6e67671a0 100644 --- a/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift +++ b/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift @@ -21,6 +21,7 @@ class IOSObservationPermissionObserver: NSObject, ObservationPermissionObserver if observationType is GPSType { let lm = CLLocationManager() let status = lm.authorizationStatus + Napier.i("GPS authorization: \(status)") return if status == .notDetermined { .notSet } else if (status == .authorizedAlways || status == .authorizedWhenInUse) && lm.accuracyAuthorization == .fullAccuracy { @@ -31,6 +32,7 @@ class IOSObservationPermissionObserver: NSObject, ObservationPermissionObserver } if observationType is AccelerometerType { let status = CMSensorRecorder.authorizationStatus() + Napier.i("CMSensorRecorder authorization: \(status)") return if status == .notDetermined { .notSet } else if status == .authorized { @@ -41,6 +43,7 @@ class IOSObservationPermissionObserver: NSObject, ObservationPermissionObserver } if observationType is PolarVerityHeartRateType { let status = CBManager.authorization + Napier.i("CBManager authorization: \(status)") return if status == .notDetermined { .notSet } else if status == .allowedAlways { @@ -51,6 +54,7 @@ class IOSObservationPermissionObserver: NSObject, ObservationPermissionObserver } if observationType is AppUsageObservationType { let status = ATTrackingManager.trackingAuthorizationStatus + Napier.i("ATTrackingManager authorization: \(status)") if status == .notDetermined { return .notSet } else if status == .authorized { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt index f19f81296..e6c4be7e7 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt @@ -68,7 +68,11 @@ data class ObservationDataEntity( inline fun fromData(data: T, timestamp: Long = -1): ObservationDataEntity { val finalTimestamp = if (timestamp > 0) { - timestamp * 1000 // Convert to milliseconds + if (timestamp < 100_000_000_000L) { + timestamp * 1000 // Convert to milliseconds + } else { + timestamp + } } else { Clock.System.now().toEpochMilliseconds() } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt index 579e9b43c..76728d475 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt @@ -104,14 +104,22 @@ abstract class Observation( return if (!running) { Napier.i(tag = "Observation::start") { "Observation with type ${observationType.observationType} starting" } applyObservationConfig(config) + permissionObserver?.let { + if (it.permissionState(this.observationType) != PermissionApprovalState.GRANTED) { + Napier.w(tag = "Observation::start") { "Observation with type ${observationType.observationType} missing permissions: ${observationType.sensorPermissions}! Requesting..." } + it.requestPermission(this.observationType) + return false + } + Napier.d { "Observation with type ${observationType.observationType} has all necessary permissions!" } + } running = start() - return running + running } else true } open fun stop(scheduleId: String, removeNotification: Boolean = false) { Napier.i(tag = "Observation::stop") { "Stopping observation of type ${observationType.observationType} for schedule $scheduleId." } - if (observationIds.size <= 1) { + if (scheduleIds.size <= 1) { stop { timestampCollectionJob?.cancel() saveAndSend() @@ -195,9 +203,6 @@ abstract class Observation( fun observerAccessible(): Boolean { val errors = observerErrors() Napier.d(tag = "Observation::observerAccessible") { errors.toString() } - Scope.launch { - updateObservationErrors() - } return errors.isEmpty() } @@ -266,9 +271,14 @@ abstract class Observation( } open fun stopAndFinish(scheduleId: String) { - Napier.i(tag = "Observation::stopAndFinish") { "Stopping and finishing observation ${observationType.observationType} for observationIds: $observationIds" } - stop { - timestampCollectionJob?.cancel() + Napier.i(tag = "Observation::stopAndFinish") { "Stopping and finishing observation ${observationType.observationType} for scheduleId: $scheduleId" } + if (scheduleIds.size <= 1) { + stop { + timestampCollectionJob?.cancel() + saveAndSend() + observationShutdown(scheduleId) + } + } else { saveAndSend() observationShutdown(scheduleId) } @@ -280,17 +290,25 @@ abstract class Observation( // Used in iOS fun stopAndSetState(state: ScheduleState = ScheduleState.ACTIVE, scheduleId: String?) { Napier.d(tag = "Observation::stopAndSetState") { "Stopping observation of type ${observationType.observationType} and setting state to $state for schedule $scheduleId." } - stop { - timestampCollectionJob?.cancel() - saveAndSend() - scheduleIds.keys.forEach { - StudyScope.launch(Dispatchers.IO) { - repos.schedule.setRunningStateFor(it, state) + if (scheduleIds.size <= 1 || scheduleId == null) { + stop { + timestampCollectionJob?.cancel() + saveAndSend() + scheduleIds.keys.forEach { + StudyScope.launch(Dispatchers.IO) { + repos.schedule.setRunningStateFor(it, state) + } + } + scheduleId?.let { + observationShutdown(it) } } - scheduleId?.let { - observationShutdown(it) + } else { + saveAndSend() + StudyScope.launch(Dispatchers.IO) { + repos.schedule.setRunningStateFor(scheduleId, state) } + observationShutdown(scheduleId) } Scope.launch { updateObservationErrors() @@ -299,14 +317,27 @@ abstract class Observation( fun stopAndSetDone(scheduleId: String) { Napier.d(tag = "Observation::stopAndSetDone") { "Stopping observation of type ${observationType.observationType} and setting done for schedule $scheduleId." } - stop { - timestampCollectionJob?.cancel() - saveAndSend() - scheduleIds.keys.forEach { - StudyScope.launch(Dispatchers.IO) { - repos.schedule.setCompletionStateFor(it, true) + if (scheduleIds.size <= 1) { + stop { + timestampCollectionJob?.cancel() + saveAndSend() + scheduleIds.keys.forEach { + StudyScope.launch(Dispatchers.IO) { + repos.schedule.setCompletionStateFor(it, true) + } + } + observationShutdown(scheduleId) + removeDataCount() + handleNotification(scheduleId) + Scope.launch { + updateObservationErrors() } } + } else { + saveAndSend() + StudyScope.launch(Dispatchers.IO) { + repos.schedule.setCompletionStateFor(scheduleId, true) + } observationShutdown(scheduleId) removeDataCount() handleNotification(scheduleId) @@ -324,11 +355,13 @@ abstract class Observation( private fun observationShutdown(scheduleId: String) { val observationId = scheduleIds.remove(scheduleId) - observationId?.let { - observationIds.remove(it) - observationTypes.remove(it) + observationId?.let { id -> + if (scheduleIds.values.none { it == id }) { + observationIds.remove(id) + observationTypes.remove(id) + } } - if (observationIds.isEmpty()) { + if (scheduleIds.isEmpty()) { config.clear() configChanged = false running = false diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt index 67da29706..e3126fbb8 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt @@ -110,7 +110,7 @@ class ObservationManager( scheduleSchemaList.removeAll { it.scheduleId == scheduleId } currentlyRunning.remove(scheduleId) } - result + return@let result } ?: false if (!result) { Napier.w(tag = "ObservationManager::start") { "Could not find observation for schema for scheduleId: $scheduleId" } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt index 6c3afad7a..ab58cd0a3 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt @@ -11,14 +11,8 @@ package io.redlink.more.observations.appUsage -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.database.repository.MainRepository -import io.redlink.more.dialog.AlertController -import io.redlink.more.dialog.AlertDialogModel -import io.redlink.more.getPlatform import io.redlink.more.logging.EventCollection import io.redlink.more.logging.EventObserver import io.redlink.more.observations.Observation @@ -29,7 +23,6 @@ import io.redlink.more.observations.observationTypes.AppUsageObservationType import io.redlink.more.services.store.PermissionApprovalState import io.redlink.more.services.store.PermissionRepository import io.redlink.more.services.store.PermissionType -import io.redlink.more.util.openSystemSettings import kotlinx.datetime.Clock class AppUsageObservation( @@ -173,20 +166,8 @@ class AppUsageObservation( permissionRepository.storeValue(BUFFER_KEY, json) } - override fun start( - observationId: String, - scheduleId: String, - notificationId: String? - ): Boolean { - if (hasPermission() != PermissionApprovalState.GRANTED) { - requestPermission() - } - val started = super.start(observationId, scheduleId, notificationId) - flushBufferIfPossible() - return started - } - override fun start(): Boolean { + flushBufferIfPossible() return true } @@ -203,24 +184,6 @@ class AppUsageObservation( if (hasPermission() == PermissionApprovalState.DECLINED) { Napier.e { "App tracking declined!" } errors.add("app_usage_tracking_declined") - if (isPermissionRequested(observationType.observationType)) { - return errors - } - markPermissionRequested(observationType.observationType) - val isAndroid = getPlatform().name.lowercase().contains("android") - val messageRes = if (isAndroid) { - SharedRes.strings.app_usage_tracking_disabled_message_android - } else { - SharedRes.strings.app_usage_tracking_disabled_message_ios - } - val model = AlertDialogModel( - title = StringDesc.Resource(SharedRes.strings.app_usage_tracking_disabled_title), - message = StringDesc.Resource(messageRes), - confirmLabel = StringDesc.Resource(SharedRes.strings.app_usage_tracking_disabled_confirm), - cancelLabel = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_negative_button), - onConfirm = { openSystemSettings() } - ) - AlertController.openAlertDialog(model) } return errors } diff --git a/shared/src/commonTest/kotlin/io/redlink/more/database/entities/ObservationDataEntityTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/database/entities/ObservationDataEntityTest.kt new file mode 100644 index 000000000..4f56a63e0 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/database/entities/ObservationDataEntityTest.kt @@ -0,0 +1,39 @@ +package io.redlink.more.database.entities + +import kotlinx.datetime.Clock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ObservationDataEntityTest { + + @Test + fun testFromDataWithSeconds() { + val seconds = 1714925000L // around May 2024 + val entity = ObservationDataEntity.fromData(mapOf("test" to "value"), seconds) + assertEquals(seconds * 1000, entity.timestamp) + } + + @Test + fun testFromDataWithMilliseconds() { + val ms = 1714925000000L // around May 2024 in ms + val entity = ObservationDataEntity.fromData(mapOf("test" to "value"), ms) + assertEquals(ms, entity.timestamp) + } + + @Test + fun testFromDataWithDefault() { + val before = Clock.System.now().toEpochMilliseconds() + val entity = ObservationDataEntity.fromData(mapOf("test" to "value")) + val after = Clock.System.now().toEpochMilliseconds() + assertTrue(entity.timestamp in before..after) + } + + @Test + fun testFromDataWithZero() { + val before = Clock.System.now().toEpochMilliseconds() + val entity = ObservationDataEntity.fromData(mapOf("test" to "value"), 0) + val after = Clock.System.now().toEpochMilliseconds() + assertTrue(entity.timestamp in before..after) + } +} From 38c55e4d72fe7e4a5e210e084c6a2847e79b805c Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Thu, 7 May 2026 10:33:37 +0200 Subject: [PATCH 6/7] #369: Multiple bug fixes: - Permission request improvements --- .../AndroidObservationPermissionObserver.kt | 14 ++++- .../IOSObservationPermissionObserver.swift | 18 ++++++ .../kotlin/io/redlink/more/Shared.kt | 4 +- .../redlink/more/observations/Observation.kt | 19 +++--- .../more/observations/ObservationFactory.kt | 58 ++++++++++++++++++- 5 files changed, 99 insertions(+), 14 deletions(-) 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 dbf33ba70..01d343a1c 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 @@ -49,8 +49,10 @@ class AndroidObservationPermissionObserver( override fun requestPermission(observationType: ObservationType) { Napier.d { "Requesting permissions for $observationType" } + MoreApplication.shared?.observationFactory?.startRequestingPermissions() if (observationType.observationType == AppUsageObservationType().observationType) { if (permissionState(observationType) != PermissionApprovalState.NOT_SET) { + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() return } AlertController.openAlertDialog( @@ -61,9 +63,11 @@ class AndroidObservationPermissionObserver( cancelLabel = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_negative_button), onConfirm = { Napier.event(LogEvent.APP_TRACKING_ACCEPTED) + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() }, onDecline = { Napier.event(LogEvent.APP_TRACKING_DECLINED) + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() } ) ) @@ -74,8 +78,14 @@ class AndroidObservationPermissionObserver( if (activity != null) { MoreApplication.shared!!.observationFactory.observation(observationType.observationType) ?.let { observation -> - PermissionUtils.requestPermissions(observation, activity) - } + PermissionUtils.requestPermissions(observation, activity) { + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() + } + } ?: run { + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() + } + } else { + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() } } } diff --git a/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift b/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift index 6e67671a0..0cdf6a6d1 100644 --- a/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift +++ b/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift @@ -69,6 +69,7 @@ class IOSObservationPermissionObserver: NSObject, ObservationPermissionObserver } func requestPermission(observationType: ObservationType) { + AppDelegate.shared.observationFactory.startRequestingPermissions() if observationType is GPSType { locationManager.delegate = self let status = locationManager.authorizationStatus @@ -78,6 +79,9 @@ class IOSObservationPermissionObserver: NSObject, ObservationPermissionObserver locationManager.requestAlwaysAuthorization() } else if status == .denied || status == .restricted || locationManager.accuracyAuthorization != .fullAccuracy { PermissionManager.openSensorPermissionDialog() + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } else { + AppDelegate.shared.observationFactory.stopRequestingPermissions() } return } @@ -91,9 +95,13 @@ class IOSObservationPermissionObserver: NSObject, ObservationPermissionObserver mam.startActivityUpdates(to: OperationQueue.main) { [weak self] _ in self?.motionActivityManager?.stopActivityUpdates() self?.motionActivityManager = nil + AppDelegate.shared.observationFactory.stopRequestingPermissions() } } else if status == .denied || status == .restricted { PermissionManager.openSensorPermissionDialog() + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } else { + AppDelegate.shared.observationFactory.stopRequestingPermissions() } return } @@ -105,6 +113,9 @@ class IOSObservationPermissionObserver: NSObject, ObservationPermissionObserver centralManager = CBCentralManager(delegate: self, queue: nil) } else if status == .denied || status == .restricted { PermissionManager.openSensorPermissionDialog() + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } else { + AppDelegate.shared.observationFactory.stopRequestingPermissions() } return } @@ -114,15 +125,20 @@ class IOSObservationPermissionObserver: NSObject, ObservationPermissionObserver if status == .notDetermined { ATTrackingManager.requestTrackingAuthorization { _ in // Observation flow will re-check via permissionState + AppDelegate.shared.observationFactory.stopRequestingPermissions() } } else if status == .denied || status == .restricted { PermissionManager.openSensorPermissionDialog() + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } else { + AppDelegate.shared.observationFactory.stopRequestingPermissions() } return } // Default fallback for other observation types PermissionManager.openSensorPermissionDialog() + AppDelegate.shared.observationFactory.stopRequestingPermissions() } } @@ -130,6 +146,7 @@ extension IOSObservationPermissionObserver: CLLocationManagerDelegate { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { // No-op: Observation flow will re-check via permissionState locationManager.delegate = nil + AppDelegate.shared.observationFactory.stopRequestingPermissions() } } @@ -137,5 +154,6 @@ extension IOSObservationPermissionObserver: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { // Release once we have a state update; prompt has been shown (if needed) centralManager = nil + AppDelegate.shared.observationFactory.stopRequestingPermissions() } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt index d9b7b9f23..6b93d699b 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt @@ -192,7 +192,6 @@ open class Shared( observationFactory.observationsWithInterface(EventObserver::class) .forEach { EventCollection.addObserver(it) } observationManager.updateTaskStates() - observationFactory.updateObservationErrors() observationService.scheduleObservationReminder() notificationManager.downloadMissedNotifications() } @@ -361,9 +360,8 @@ open class Shared( } } - suspend fun newLogin() { + fun newLogin() { notificationManager.newFCMToken() - observationFactory.updateObservationErrors() garminLogin() } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt index 76728d475..1132f11c4 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt @@ -102,17 +102,13 @@ abstract class Observation( } configChanged = false return if (!running) { - Napier.i(tag = "Observation::start") { "Observation with type ${observationType.observationType} starting" } + Napier.i(tag = "Observation::start") { "Observation with type ${observationType.observationType} starting..." } applyObservationConfig(config) permissionObserver?.let { - if (it.permissionState(this.observationType) != PermissionApprovalState.GRANTED) { - Napier.w(tag = "Observation::start") { "Observation with type ${observationType.observationType} missing permissions: ${observationType.sensorPermissions}! Requesting..." } - it.requestPermission(this.observationType) - return false - } - Napier.d { "Observation with type ${observationType.observationType} has all necessary permissions!" } + updateObservationPermissions() } running = start() + Napier.i { "Observation with type ${observationType.observationType} started: $running" } running } else true } @@ -208,6 +204,15 @@ abstract class Observation( protected open fun observerErrors(): Set = emptySet() + fun updateObservationPermissions() { + if (hasPermission() != PermissionApprovalState.GRANTED) { + Napier.w { "Permissions not given for observation ${observationType.observationType}! Requesting permissions..." } + requestPermission() + } else { + Napier.d { "All permissions given for observation ${observationType.observationType}!" } + } + } + suspend fun updateObservationErrors() { repos.schedule.allSchedulesToday(observationType).firstOrNull()?.let { if (it.isNotEmpty()) { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt index b73c60ed1..ac6ffb7e5 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt @@ -26,6 +26,8 @@ import io.redlink.more.services.store.CredentialRepository import io.redlink.more.services.store.PermissionRepository import io.redlink.more.services.store.PermissionRepositoryImpl import io.redlink.more.services.store.SharedStorageRepository +import io.redlink.more.viewModels.ViewManager +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlin.reflect.KClass @@ -34,8 +36,31 @@ abstract class ObservationFactory( repository: MainRepository, sharedStorageRepository: SharedStorageRepository, private val dataManager: ObservationDataManager, - scope: MoreScope = Scope + private val scope: MoreScope = Scope ) { + private var isRequestingPermissions = false + private var updateErrorsDeferred = false + + private var activePermissionRequests = 0 + + fun startRequestingPermissions() { + activePermissionRequests++ + isRequestingPermissions = true + } + + fun stopRequestingPermissions() { + activePermissionRequests-- + if (activePermissionRequests <= 0) { + activePermissionRequests = 0 + isRequestingPermissions = false + if (updateErrorsDeferred) { + updateErrorsDeferred = false + scope.launch(AppDispatchers.default) { + updateObservationErrors() + } + } + } + } private var credentialRepository: CredentialRepository? = null open val observations = mutableSetOf() @@ -53,11 +78,23 @@ abstract class ObservationFactory( init { registerImportantObservations(repository, permissionRepository) registerNormalObservations(repository) - scope.launch(AppDispatchers.io) { + scope.launch(AppDispatchers.default) { repository.observation.observationTypes().collect { Napier.i(tag = "ObservationFactory::init") { "Observation types fetched: $it" } initializeNeededObservations(it) _studyObservationTypes.value = it + if (it.isNotEmpty()) { + scope.launch(AppDispatchers.default) { + Napier.d { "Updating observation permissions..." } + while (!ViewManager.appInForeground.value) { + Napier.d { "App not in foreground! Waiting for permission check..." } + delay(100) + } + updateObservationPermissions() + updateObservationErrors() + Napier.d { "Updated permission check!" } + } + } } } } @@ -161,11 +198,28 @@ abstract class ObservationFactory( } open suspend fun updateObservationErrors() { + if (isRequestingPermissions) { + updateErrorsDeferred = true + Napier.d { "Observation error update blocked while requesting permissions. Deferring..." } + return + } if (this.credentialRepository?.hasCredentials?.value == true) { + Napier.d { "Updating Observation errors..." } studyObservations().forEach { it.updateObservationErrors() } } } + fun updateObservationPermissions() { + if (this.credentialRepository?.hasCredentials?.value == true) { + startRequestingPermissions() + Napier.d { "Updating Observation permissions for types ${studyObservations()}..." } + studyObservations().forEach { + it.updateObservationPermissions() + } + stopRequestingPermissions() + } + } + open fun observation(type: String): Observation? { Napier.i(tag = "ObservationFactory::observation") { "Fetching observation of type: $type" } val observation = observations.firstOrNull { From c59797fb7d849b00fade63eb8518e2b65d0d702c Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Thu, 7 May 2026 14:05:29 +0200 Subject: [PATCH 7/7] #369: Multiple bug fixes: - Fixed an view foreground/background issue --- .../more/observations/ObservationFactory.kt | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt index ac6ffb7e5..2e5f49329 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt @@ -27,9 +27,13 @@ import io.redlink.more.services.store.PermissionRepository import io.redlink.more.services.store.PermissionRepositoryImpl import io.redlink.more.services.store.SharedStorageRepository import io.redlink.more.viewModels.ViewManager +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.yield import kotlin.reflect.KClass abstract class ObservationFactory( @@ -79,21 +83,12 @@ abstract class ObservationFactory( registerImportantObservations(repository, permissionRepository) registerNormalObservations(repository) scope.launch(AppDispatchers.default) { - repository.observation.observationTypes().collect { + repository.observation.observationTypes().collectLatest { Napier.i(tag = "ObservationFactory::init") { "Observation types fetched: $it" } initializeNeededObservations(it) _studyObservationTypes.value = it if (it.isNotEmpty()) { - scope.launch(AppDispatchers.default) { - Napier.d { "Updating observation permissions..." } - while (!ViewManager.appInForeground.value) { - Napier.d { "App not in foreground! Waiting for permission check..." } - delay(100) - } - updateObservationPermissions() - updateObservationErrors() - Napier.d { "Updated permission check!" } - } + updateObservationPermissionsAndErrorsWhenInForeground() } } } @@ -197,6 +192,27 @@ abstract class ObservationFactory( return autoStartTypes } + private suspend fun updateObservationPermissionsAndErrorsWhenInForeground() { + withTimeoutOrNull(300_000L) { + ViewManager.appInForeground.collectLatest { inForeground -> + if (inForeground) { + Napier.d { "App in foreground, updating observation permissions and errors..." } + updateObservationPermissions() + updateObservationErrors() + Napier.d { "Updated permission check!" } + cancel() + } else { + var currentLogDelay = 1000L + while (true) { + Napier.d { "App not in foreground! Waiting for permission check..." } + delay(currentLogDelay) + currentLogDelay = (currentLogDelay * 2).coerceAtMost(30000L) + } + } + } + } + } + open suspend fun updateObservationErrors() { if (isRequestingPermissions) { updateErrorsDeferred = true @@ -209,14 +225,18 @@ abstract class ObservationFactory( } } - fun updateObservationPermissions() { + suspend fun updateObservationPermissions() { if (this.credentialRepository?.hasCredentials?.value == true) { startRequestingPermissions() - Napier.d { "Updating Observation permissions for types ${studyObservations()}..." } - studyObservations().forEach { - it.updateObservationPermissions() + try { + Napier.d { "Updating Observation permissions for types ${studyObservations()}..." } + studyObservations().forEach { + yield() + it.updateObservationPermissions() + } + } finally { + stopRequestingPermissions() } - stopRequestingPermissions() } }