From 7c6745a053ebc1bab398844c205d1e140f9a9b99 Mon Sep 17 00:00:00 2001 From: Eldi Cano Date: Sun, 8 Dec 2024 23:21:42 +0100 Subject: [PATCH 1/3] request notification permissions in home --- .../bluetooth/screen/HomeScreenTest.kt | 75 +++ .../engagehf/simulator/HomeScreenSimulator.kt | 36 ++ .../edu/stanford/bdh/engagehf/MainActivity.kt | 6 +- .../bdh/engagehf/bluetooth/HomeViewModel.kt | 367 +++++++++++ .../data/mapper/BluetoothUiStateMapper.kt | 1 - .../engagehf/bluetooth/data/models/Action.kt | 2 +- .../bluetooth/data/models/BluetoothUiState.kt | 1 - .../engagehf/bluetooth/data/models/UiState.kt | 1 + .../engagehf/bluetooth/screen/HomeScreen.kt | 357 +++++++++++ .../engagehf/navigation/screens/AppScreen.kt | 4 +- .../engagehf/bluetooth/HomeViewModelTest.kt | 586 ++++++++++++++++++ .../data/mapper/BluetoothUiStateMapperTest.kt | 19 - .../onboarding/EngageConsentManagerTest.kt | 2 +- .../design/component/PermissionRequester.kt | 8 +- .../notification/NotificationPermissions.kt | 28 + .../notification/di/NotificationModule.kt | 15 +- .../setting/NotificationSettingScreen.kt | 64 +- .../setting/NotificationSettingViewModel.kt | 36 +- .../NotificationPermissionsTest.kt | 59 ++ .../NotificationSettingViewModelTest.kt | 70 ++- .../stanford/spezi/core/utils/BuildInfo.kt | 12 + .../spezi/core/utils/di/UtilsModule.kt | 5 + .../spezi/core/utils/BuildInfoTest.kt | 14 + 23 files changed, 1645 insertions(+), 123 deletions(-) create mode 100644 app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/bluetooth/screen/HomeScreenTest.kt create mode 100644 app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/HomeScreenSimulator.kt create mode 100644 app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/HomeViewModel.kt create mode 100644 app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/screen/HomeScreen.kt create mode 100644 app/src/test/kotlin/edu/stanford/bdh/engagehf/bluetooth/HomeViewModelTest.kt create mode 100644 core/notification/src/main/kotlin/edu/stanford/spezi/core/notification/NotificationPermissions.kt create mode 100644 core/notification/src/test/kotlin/edu/stanford/spezi/core/notification/NotificationPermissionsTest.kt create mode 100644 core/utils/src/main/kotlin/edu/stanford/spezi/core/utils/BuildInfo.kt create mode 100644 core/utils/src/test/kotlin/edu/stanford/spezi/core/utils/BuildInfoTest.kt diff --git a/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/bluetooth/screen/HomeScreenTest.kt b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/bluetooth/screen/HomeScreenTest.kt new file mode 100644 index 000000000..678df08c3 --- /dev/null +++ b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/bluetooth/screen/HomeScreenTest.kt @@ -0,0 +1,75 @@ +package edu.stanford.bdh.engagehf.bluetooth.screen + +import android.Manifest +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.rule.GrantPermissionRule +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import edu.stanford.bdh.engagehf.R +import edu.stanford.bdh.engagehf.bluetooth.data.models.UiState +import edu.stanford.bdh.engagehf.simulator.HomeScreenSimulator +import edu.stanford.spezi.core.design.component.ComposeContentActivity +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class HomeScreenTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @get:Rule + val runtimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.POST_NOTIFICATIONS, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + ) + + @Before + fun init() { + composeTestRule.activity.setScreen { + HomeScreen() + } + } + + @Test + fun `test home screen root is displayed`() { + homeScreen { + assertIsDisplayed() + } + } + + @Test + fun `test home screen message title is displayed`() { + homeScreen { + assertMessageTitle(composeTestRule.activity.getString(R.string.messages)) + } + } + + @Test + fun `test home screen vital title is displayed`() { + homeScreen { + assertVitalTitle(composeTestRule.activity.getString(R.string.vitals)) + } + } + + @Test + fun `test home screen vital is displayed`() { + homeScreen { + val uiState = UiState() + assertVital(uiState.weight.title) + assertVital(uiState.heartRate.title) + assertVital(uiState.bloodPressure.title) + } + } + + private fun homeScreen(block: HomeScreenSimulator.() -> Unit) { + HomeScreenSimulator(composeTestRule).apply(block) + } +} diff --git a/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/HomeScreenSimulator.kt b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/HomeScreenSimulator.kt new file mode 100644 index 000000000..3412c8ddb --- /dev/null +++ b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/HomeScreenSimulator.kt @@ -0,0 +1,36 @@ +package edu.stanford.bdh.engagehf.simulator + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeTestRule +import edu.stanford.bdh.engagehf.bluetooth.screen.HomeScreenTestIdentifier +import edu.stanford.spezi.core.testing.onNodeWithIdentifier + +class HomeScreenSimulator( + private val composeTestRule: ComposeTestRule, +) { + private val root = composeTestRule.onNodeWithIdentifier(HomeScreenTestIdentifier.ROOT) + + private val messageTitle = + composeTestRule.onNodeWithIdentifier(HomeScreenTestIdentifier.MESSAGE_TITLE) + + private val vitalTitle = + composeTestRule.onNodeWithIdentifier(HomeScreenTestIdentifier.VITAL_TITLE) + + fun assertVital(vitalTitle: String) { + composeTestRule.onNodeWithIdentifier(HomeScreenTestIdentifier.VITALS, vitalTitle) + .assertIsDisplayed() + } + + fun assertIsDisplayed() { + root.assertIsDisplayed() + } + + fun assertMessageTitle(text: String) { + messageTitle.assertIsDisplayed().assertTextEquals(text) + } + + fun assertVitalTitle(text: String) { + vitalTitle.assertIsDisplayed().assertTextEquals(text) + } +} diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/MainActivity.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/MainActivity.kt index 15ba15ad1..304c37108 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/MainActivity.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/MainActivity.kt @@ -22,7 +22,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import dagger.hilt.android.AndroidEntryPoint -import edu.stanford.bdh.engagehf.bluetooth.BluetoothViewModel +import edu.stanford.bdh.engagehf.bluetooth.HomeViewModel import edu.stanford.bdh.engagehf.bluetooth.data.models.Action import edu.stanford.bdh.engagehf.contact.ui.ContactScreen import edu.stanford.bdh.engagehf.navigation.AppNavigationEvent @@ -59,7 +59,7 @@ class MainActivity : FragmentActivity() { private val viewModel by viewModels() - private val bluetoothViewModel by viewModels() + private val homeViewModel by viewModels() @Inject @Dispatching.Main @@ -67,7 +67,7 @@ class MainActivity : FragmentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - bluetoothViewModel.onAction(Action.NewIntent(intent)) + homeViewModel.onAction(Action.NewIntent(intent)) } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/HomeViewModel.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/HomeViewModel.kt new file mode 100644 index 000000000..15719d2ff --- /dev/null +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/HomeViewModel.kt @@ -0,0 +1,367 @@ +package edu.stanford.bdh.engagehf.bluetooth + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import edu.stanford.bdh.engagehf.R +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents +import edu.stanford.bdh.engagehf.bluetooth.data.mapper.BluetoothUiStateMapper +import edu.stanford.bdh.engagehf.bluetooth.data.models.Action +import edu.stanford.bdh.engagehf.bluetooth.data.models.UiState +import edu.stanford.bdh.engagehf.bluetooth.measurements.MeasurementsRepository +import edu.stanford.bdh.engagehf.bluetooth.service.EngageBLEService +import edu.stanford.bdh.engagehf.bluetooth.service.EngageBLEServiceEvent +import edu.stanford.bdh.engagehf.bluetooth.service.EngageBLEServiceState +import edu.stanford.bdh.engagehf.education.EngageEducationRepository +import edu.stanford.bdh.engagehf.messages.HealthSummaryService +import edu.stanford.bdh.engagehf.messages.Message +import edu.stanford.bdh.engagehf.messages.MessageRepository +import edu.stanford.bdh.engagehf.messages.MessageType +import edu.stanford.bdh.engagehf.messages.MessagesAction +import edu.stanford.bdh.engagehf.navigation.AppNavigationEvent +import edu.stanford.bdh.engagehf.navigation.screens.BottomBarItem +import edu.stanford.spezi.core.logging.speziLogger +import edu.stanford.spezi.core.navigation.Navigator +import edu.stanford.spezi.core.notification.NotificationPermissions +import edu.stanford.spezi.core.notification.notifier.FirebaseMessage +import edu.stanford.spezi.core.notification.notifier.FirebaseMessage.Companion.FIREBASE_MESSAGE_KEY +import edu.stanford.spezi.core.utils.MessageNotifier +import edu.stanford.spezi.modules.education.EducationNavigationEvent +import edu.stanford.spezi.modules.education.videos.Video +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +@Suppress("LongParameterList") +class HomeViewModel @Inject internal constructor( + private val bleService: EngageBLEService, + private val uiStateMapper: BluetoothUiStateMapper, + private val measurementsRepository: MeasurementsRepository, + private val messageRepository: MessageRepository, + private val appScreenEvents: AppScreenEvents, + private val navigator: Navigator, + private val engageEducationRepository: EngageEducationRepository, + private val healthSummaryService: HealthSummaryService, + private val messageNotifier: MessageNotifier, + @ApplicationContext private val context: Context, + notificationPermissions: NotificationPermissions, +) : ViewModel() { + private val logger by speziLogger() + + private val _uiState = MutableStateFlow( + UiState( + missingPermissions = notificationPermissions.getRequiredPermissions() + ) + ) + + val uiState = _uiState.asStateFlow() + + init { + bleService.start() + observeBleService() + observeRecords() + observeMessages() + } + + private fun observeBleService() { + viewModelScope.launch { + bleService.state.collect { state -> + logger.i { "Received EngageBLEService state $state" } + _uiState.update { currentState -> + val missingPermissions = currentState.missingPermissions.toMutableSet() + if (state is EngageBLEServiceState.MissingPermissions) { + missingPermissions.addAll(state.permissions) + } + currentState.copy( + bluetooth = uiStateMapper.mapBleServiceState(state), + missingPermissions = missingPermissions, + ) + } + } + } + + viewModelScope.launch { + bleService.events.collect { event -> + logger.i { "Received BLEService event $event" } + when (event) { + is EngageBLEServiceEvent.MeasurementReceived -> { + appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) + _uiState.update { + val dialog = uiStateMapper.mapMeasurementDialog(event.measurement) + it.copy(measurementDialog = dialog) + } + } + + is EngageBLEServiceEvent.DeviceDiscovered, + is EngageBLEServiceEvent.DeviceConnected, + is EngageBLEServiceEvent.DevicePaired, + -> { + logger.i { "Ignoring event $event" } + } + } + } + } + } + + private fun observeRecords() { + viewModelScope.launch { + measurementsRepository.observeBloodPressureRecord().collect { result -> + _uiState.update { it.copy(bloodPressure = uiStateMapper.mapBloodPressure(result)) } + } + } + + viewModelScope.launch { + measurementsRepository.observeHeartRateRecord().collect { result -> + _uiState.update { it.copy(heartRate = uiStateMapper.mapHeartRate(result)) } + } + } + + viewModelScope.launch { + measurementsRepository.observeWeightRecord().collect { result -> + _uiState.update { it.copy(weight = uiStateMapper.mapWeight(result)) } + } + } + } + + private fun observeMessages() { + viewModelScope.launch { + messageRepository.observeUserMessages().collect { messages -> + _uiState.update { + it.copy( + messages = messages + ) + } + } + } + } + + fun onAction(action: Action) { + when (action) { + is Action.ConfirmMeasurement -> { + handleConfirmMeasurementAction(action) + } + + is Action.BLEDevicePairing -> { + appScreenEvents.emit(AppScreenEvents.Event.BLEDevicePairingBottomSheet) + } + + is Action.DismissDialog -> { + _uiState.update { + it.copy(measurementDialog = it.measurementDialog.copy(isVisible = false)) + } + } + + is Action.MessageItemClicked -> { + onMessageClicked(message = action.message) + } + + is Action.ToggleExpand -> { + handleToggleExpandAction(action) + } + + is Action.Resumed -> { + bleService.start() + } + + is Action.PermissionResult -> { + _uiState.update { state -> + val missingPermission = state.missingPermissions.filterNot { it == action.permission } + state.copy(missingPermissions = missingPermission.toSet()) + } + bleService.start() + } + + is Action.Settings.AppSettings -> { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + launch(intent = intent) + } + + is Action.Settings.BluetoothSettings -> { + launch(intent = Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) + } + + is Action.NewIntent -> handleNewIntent(action.intent) + is Action.VitalsCardClicked -> appScreenEvents.emit( + AppScreenEvents.Event.NavigateToTab( + BottomBarItem.HEART_HEALTH + ) + ) + } + } + + private fun onMessageClicked(message: Message) { + viewModelScope.launch { + uiStateMapper.mapMessagesAction(message.action) + .onFailure { error -> + logger.e(error) { "Error while mapping action: ${message.action}" } + } + .onSuccess { messagesAction -> + messagesAction?.let { + onMessage( + messagesAction = it, + messageId = message.id, + isDismissible = message.isDismissible, + ) + } + } + } + } + + private fun handleNewIntent(intent: Intent) { + val firebaseMessage = + intent.getParcelableExtra(FIREBASE_MESSAGE_KEY) + firebaseMessage?.messageId?.let { messageId -> + viewModelScope.launch { + onAction( + Action.MessageItemClicked( + message = Message( + id = messageId, // Is needed to dismiss the message + type = MessageType.Unknown, // We don't need the type, since we directly use the action + title = "", // We don't need the title, since we directly use the action + action = firebaseMessage.action, // We directly use the action + isDismissible = firebaseMessage.isDismissible + ?: false + ) + ) + ) + } + } + } + + private fun launch(intent: Intent) { + runCatching { + context.startActivity(intent.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) + }.onFailure { + logger.e(it) { "Failed to launch intent ${intent.action}" } + } + } + + private suspend fun onMessage( + messagesAction: MessagesAction, + messageId: String, + isDismissible: Boolean, + ) { + var shouldDismissMessage = isDismissible + when (messagesAction) { + is MessagesAction.HealthSummaryAction -> { + handleHealthSummaryAction(messageId).onFailure { + shouldDismissMessage = false + } + } + + is MessagesAction.VideoSectionAction -> { + handleVideoSectionAction(messagesAction).onFailure { + shouldDismissMessage = false + } + } + + is MessagesAction.MeasurementsAction -> { + appScreenEvents.emit(AppScreenEvents.Event.DoNewMeasurement) + } + + is MessagesAction.MedicationsAction -> { + appScreenEvents.emit( + AppScreenEvents.Event.NavigateToTab( + BottomBarItem.MEDICATION + ) + ) + } + + is MessagesAction.QuestionnaireAction -> { + navigator.navigateTo( + AppNavigationEvent.QuestionnaireScreen( + messagesAction.questionnaireId + ) + ) + } + } + if (shouldDismissMessage) { + messageRepository.completeMessage(messageId = messageId) + } + } + + private suspend fun handleVideoSectionAction(messageAction: MessagesAction.VideoSectionAction): Result