Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
package com.github.warnastrophy.core.ui.components

import android.Manifest
import android.content.Context
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.github.warnastrophy.WarnastrophyComposable
import com.github.warnastrophy.core.data.provider.ContactRepositoryProvider
import com.github.warnastrophy.core.data.provider.UserPreferencesRepositoryProvider
import com.github.warnastrophy.core.data.service.StateManagerService
import com.github.warnastrophy.core.ui.features.dashboard.ConfirmationPopup
import com.github.warnastrophy.core.ui.features.dashboard.ConfirmationPopupTestTags
import com.github.warnastrophy.core.util.BaseAndroidComposeTest
import com.github.warnastrophy.userPrefsDataStore
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class ConfirmationPopupTest : BaseAndroidComposeTest() {

@get:Rule
val permissionRule: GrantPermissionRule =
GrantPermissionRule.grant(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.POST_NOTIFICATIONS)

private fun setPopupContent(onConfirm: () -> Unit = {}, onCancel: () -> Unit = {}) {
composeTestRule.setContent {
ConfirmationPopup(
Expand Down Expand Up @@ -53,4 +72,48 @@ class ConfirmationPopupTest : BaseAndroidComposeTest() {

assertEquals(1, cancelCount)
}

@Test
fun confirmationPopup_shows_and_confirm_calls_orchestrator() {
val ctx = ApplicationProvider.getApplicationContext<Context>()
UserPreferencesRepositoryProvider.initLocal(ctx.userPrefsDataStore)
ContactRepositoryProvider.initLocal(ctx)
InstrumentationRegistry.getInstrumentation().runOnMainSync { StateManagerService.init(ctx) }
// Launch the app composable
composeTestRule.setContent { WarnastrophyComposable() }

// Trigger the touch confirmation via the orchestrator debug helper
val orchestrator = StateManagerService.dangerModeOrchestrator

// Ensure touch confirmation is required
orchestrator.setTouchConfirmationRequired(true)

// Trigger touch confirmation
composeTestRule.runOnUiThread { orchestrator.debugTriggerTouchConfirmation() }

// Wait for popup and assert it's visible
composeTestRule.waitUntilWithTimeout {
try {
composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.DIALOG).assertIsDisplayed()
true
} catch (_: Throwable) {
false
}
}

composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.DIALOG).assertIsDisplayed()

// Click confirm button
composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.CONFIRM_BUTTON).performClick()

// After confirm, popup should no longer be displayed
composeTestRule.waitUntilWithTimeout {
try {
composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.DIALOG).assertIsNotDisplayed()
true
} catch (_: Throwable) {
false
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
Expand All @@ -31,6 +32,7 @@ import com.github.warnastrophy.core.ui.features.contact.ContactListScreen
import com.github.warnastrophy.core.ui.features.contact.ContactListViewModel
import com.github.warnastrophy.core.ui.features.contact.EditContactScreen
import com.github.warnastrophy.core.ui.features.contact.EditContactViewModel
import com.github.warnastrophy.core.ui.features.dashboard.ConfirmationPopup
import com.github.warnastrophy.core.ui.features.dashboard.DashboardScreen
import com.github.warnastrophy.core.ui.features.dashboard.activity.ActivityListScreen
import com.github.warnastrophy.core.ui.features.dashboard.activity.ActivityListViewModel
Expand Down Expand Up @@ -151,6 +153,9 @@ fun WarnastrophyComposable(
// Voice confirmation for danger mode
val dangerModeOrchestrator = remember { StateManagerService.dangerModeOrchestrator }
val showVoiceConfirmation by dangerModeOrchestrator.showVoiceConfirmationScreen.collectAsState()
val showTouchConfirmation by
dangerModeOrchestrator.showTouchConfirmationScreen.collectAsState(initial = false)
val orchestratorState by dangerModeOrchestrator.state.collectAsState()

// Observe confirmation state from the communication view model
val communicationUiState by communicationViewModel.uiState.collectAsState()
Expand Down Expand Up @@ -182,6 +187,12 @@ fun WarnastrophyComposable(
return // Don't render the rest of the UI while voice confirmation is showing
}

// Show touch confirmation using the generic ConfirmationPopup when requested
if (showTouchConfirmation) {
TouchConfirmationPopup(dangerModeOrchestrator, orchestratorState.pendingAction)
return // Don't render the rest of the UI while touch confirmation is showing
}

Scaffold(
modifier = Modifier.testTag(WarnastrophyAppTestTags.MAIN_SCREEN),
bottomBar = { BottomNavigationBar(currentScreen, navController) },
Expand Down Expand Up @@ -292,3 +303,35 @@ fun WarnastrophyComposable(
}
}
}

/**
* Shows a touch confirmation popup for the given pending emergency action. Extracted to keep the
* main composable concise.
*
* @param orchestrator The DangerModeOrchestrator managing danger mode state.
* @param pendingAction The pending emergency action requiring confirmation.
*/
@Composable
Comment thread
frakdp marked this conversation as resolved.
private fun TouchConfirmationPopup(
Comment thread
frakdp marked this conversation as resolved.
orchestrator: com.github.warnastrophy.core.data.service.DangerModeOrchestrator,
pendingAction: com.github.warnastrophy.core.data.service.PendingEmergencyAction?
) {
val message =
when (pendingAction) {
is com.github.warnastrophy.core.data.service.PendingEmergencyAction.MakeCall ->
stringResource(id = R.string.touch_confirmation_call_message)
is com.github.warnastrophy.core.data.service.PendingEmergencyAction.SendSms ->
stringResource(id = R.string.touch_confirmation_sms_message)
is com.github.warnastrophy.core.data.service.PendingEmergencyAction.SendSmsAndCall ->
stringResource(id = R.string.touch_confirmation_both_message)
else -> stringResource(id = R.string.touch_confirmation_generic_message)
}

ConfirmationPopup(
title = stringResource(id = R.string.touch_confirmation_title),
message = message,
confirmLabel = stringResource(id = R.string.confirmation_confirm_label),
cancelLabel = stringResource(id = R.string.confirmation_cancel_label),
onConfirm = { orchestrator.onTouchConfirmation() },
onCancel = { orchestrator.onCancellation() })
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,22 @@ class DangerModeOrchestrator(
private val _showVoiceConfirmationScreen = MutableStateFlow(false)
val showVoiceConfirmationScreen: StateFlow<Boolean> = _showVoiceConfirmationScreen.asStateFlow()

private val _showTouchConfirmationScreen = MutableStateFlow(false)
val showTouchConfirmationScreen: StateFlow<Boolean> = _showTouchConfirmationScreen.asStateFlow()

private var _voiceConfirmationEnabled = false
private var _touchConfirmationRequired = false

/** Sets whether voice confirmation is required before executing emergency actions. */
fun setVoiceConfirmationEnabled(enabled: Boolean) {
_voiceConfirmationEnabled = enabled
}

/** Sets whether touch confirmation is required before executing emergency actions. */
fun setTouchConfirmationRequired(required: Boolean) {
_touchConfirmationRequired = required
}

private var smsSenderInstance: SmsSender? = smsSender
private var callSenderInstance: CallSender? = callSender

Expand All @@ -137,6 +146,19 @@ class DangerModeOrchestrator(
}
}
}

// Observe persisted user preferences and update confirmation requirement flags.
scope.launch {
try {
userPreferencesRepository.getUserPreferences.collectLatest { prefs ->
// Keep in-memory flags in sync with repository so orchestrator reacts immediately
_voiceConfirmationEnabled = prefs.dangerModePreferences.voiceConfirmationEnabled
_touchConfirmationRequired = prefs.dangerModePreferences.touchConfirmationRequired
}
} catch (_: Exception) {
// Ignore failures to read preferences at startup; defaults remain
}
}
}

/**
Expand Down Expand Up @@ -302,12 +324,15 @@ class DangerModeOrchestrator(
pendingAction = pendingAction,
confirmationTimeoutSeconds = CONFIRMATION_TIMEOUT_SECONDS)

// Check if voice confirmation is enabled
// Check confirmation mode: prefer voice, otherwise touch if required by prefs or runtime flag
if (_voiceConfirmationEnabled) {
// Show voice confirmation screen
_showVoiceConfirmationScreen.value = true
} else if (_touchConfirmationRequired || preferences.touchConfirmationRequired) {
// Show touch confirmation screen
_showTouchConfirmationScreen.value = true
} else {
// Execute action directly without voice confirmation
// Execute action directly without confirmation
scope.launch { executeEmergencyAction(pendingAction) }
}
}
Expand All @@ -321,13 +346,23 @@ class DangerModeOrchestrator(
scope.launch { executeEmergencyAction(pendingAction) }
}

/** Called when the user confirms the emergency action via touch confirmation UI. */
fun onTouchConfirmation() {
val pendingAction = _state.value.pendingAction ?: return

_showTouchConfirmationScreen.value = false

scope.launch { executeEmergencyAction(pendingAction) }
}

/**
* Called when the user cancels the emergency action via voice ("no") or timeout.
*
* @param reportAsError If true, reports the cancellation to the ErrorHandler.
*/
fun onCancellation(reportAsError: Boolean = false) {
_showVoiceConfirmationScreen.value = false
_showTouchConfirmationScreen.value = false
_state.value = OrchestratorState(lastActionTaken = EmergencyActionResult.Cancelled)

if (reportAsError) {
Expand Down Expand Up @@ -410,6 +445,7 @@ class DangerModeOrchestrator(
private fun resetState() {
_state.value = OrchestratorState()
_showVoiceConfirmationScreen.value = false
_showTouchConfirmationScreen.value = false
_permissionRequests.value = null
}

Expand All @@ -433,4 +469,19 @@ class DangerModeOrchestrator(
confirmationTimeoutSeconds = CONFIRMATION_TIMEOUT_SECONDS)
_showVoiceConfirmationScreen.value = true
}

/** DEBUG ONLY: Manually trigger the touch confirmation screen for testing purposes. */
Comment thread
frakdp marked this conversation as resolved.
fun debugTriggerTouchConfirmation() {
val currentLocation = gpsService.positionState.value.position
val emergencyMessage =
EmergencyMessage(location = Location(currentLocation.latitude, currentLocation.longitude))

_state.value =
OrchestratorState(
isWaitingForConfirmation = true,
pendingAction =
PendingEmergencyAction.SendSmsAndCall(emergencyPhoneNumber, emergencyMessage),
confirmationTimeoutSeconds = CONFIRMATION_TIMEOUT_SECONDS)
_showTouchConfirmationScreen.value = true
}
}
7 changes: 7 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,11 @@
<string name="error_suggestion_no_emergency_contact">Add an emergency contact in your profile to enable automatic SMS and call features.</string>

<string name="voice_confirmation_title">Confirm Emergency Action</string>
<string name="touch_confirmation_title">Confirm emergency action</string>
<string name="touch_confirmation_call_message">A phone call will be placed to your emergency contact. Do you want to proceed?</string>
<string name="touch_confirmation_sms_message">A text message containing your current location will be sent to your emergency contact. Do you want to proceed?</string>
<string name="touch_confirmation_both_message">A text message will be sent and then a phone call will be placed to your emergency contact. Do you want to proceed?</string>
<string name="touch_confirmation_generic_message">An emergency action will be performed (call/SMS) to your emergency contact. Do you want to proceed?</string>
<string name="confirmation_confirm_label">Confirm</string>
<string name="confirmation_cancel_label">Cancel</string>
</resources>
Loading