From 228895514290a924954d292ff7b2ace55a4f99b8 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 10 Aug 2025 23:12:51 -0700 Subject: [PATCH 1/8] Add nav3 libs --- app/build.gradle.kts | 4 ++++ gradle/libs.versions.toml | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2adea3d..eb3c4ca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,6 +93,10 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(libs.androidx.material3.navigation3) implementation(libs.timber) implementation(libs.kim) implementation(project.dependencies.platform(libs.koin.bom)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5796bba..ee18313 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,9 @@ junitVersion = "1.3.0" activityCompose = "1.10.1" composeBom = "2025.07.00" navigationCompose = "2.9.3" +navigation3 = "1.0.0-alpha06" +nav3Material = "1.0.0-SNAPSHOT" +lifecycleViewmodel = "1.0.0-SNAPSHOT" koin-bom = "4.1.0" cryptographyBom = "0.5.0" datastorePreferences = "1.1.7" @@ -93,6 +96,11 @@ ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomable" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workManager" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodel" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } +androidx-material3-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "nav3Material" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 395c9a0e3a6029885f4f85a42f33a07c73503bcc Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 11 Aug 2025 19:20:31 -0700 Subject: [PATCH 2/8] Add nav3 libs --- app/build.gradle.kts | 1 - .../darkrockstudios/app/securecamera/App.kt | 14 +- .../app/securecamera/MainActivity.kt | 17 +- .../app/securecamera/about/AboutContent.kt | 4 +- .../auth/PinVerificationContent.kt | 34 +- .../camera/BottomCameraControls.kt | 4 +- .../app/securecamera/camera/CameraContent.kt | 4 +- .../app/securecamera/camera/CameraControls.kt | 6 +- .../securecamera/camera/NoCameraPermission.kt | 4 +- .../securecamera/gallery/GalleryContent.kt | 2 +- .../app/securecamera/gallery/GalleryTopNav.kt | 2 +- .../import/ImportPhotosContent.kt | 15 +- .../securecamera/import/ImportPhotosTopBar.kt | 10 +- .../introduction/IntroductionContent.kt | 13 +- .../navigation/AppDestinations.kt | 36 +- .../app/securecamera/navigation/AppKeys.kt | 62 ++++ .../securecamera/navigation/AppNavigation.kt | 331 +++++++----------- .../navigation/Nav3CompatController.kt | 92 +++++ .../obfuscation/ObfuscatePhotoContent.kt | 38 +- .../securecamera/settings/SettingsContent.kt | 9 +- .../app/securecamera/ui/UiEventHandler.kt | 2 +- .../viewphoto/ViewPhotoContent.kt | 6 +- .../securecamera/viewphoto/ViewPhotoTopBar.kt | 2 +- gradle/libs.versions.toml | 4 +- settings.gradle.kts | 2 + 25 files changed, 356 insertions(+), 358 deletions(-) create mode 100644 app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppKeys.kt create mode 100644 app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eb3c4ca..919e03f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,7 +92,6 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) - implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.lifecycle.viewmodel.navigation3) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/App.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/App.kt index 45bba75..9ee39ed 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/App.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/App.kt @@ -7,9 +7,10 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.navigation.NavHostController +import androidx.navigation3.runtime.NavBackStack import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository import com.darkrockstudios.app.securecamera.navigation.AppNavHost +import com.darkrockstudios.app.securecamera.navigation.NavController import com.darkrockstudios.app.securecamera.navigation.enforceAuth import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource import com.darkrockstudios.app.securecamera.ui.theme.SecureCameraTheme @@ -19,8 +20,8 @@ import org.koin.compose.koinInject @Composable fun App( capturePhoto: MutableState, - startDestination: String, - navController: NavHostController + backStack: NavBackStack, + navController: NavController ) { KoinContext { SecureCameraTheme { @@ -38,11 +39,11 @@ fun App( modifier = Modifier.imePadding() ) { paddingValues -> AppNavHost( + backStack = backStack, navController = navController, capturePhoto = capturePhoto, modifier = Modifier, snackbarHostState = snackbarHostState, - startDestination = startDestination, paddingValues = paddingValues, ) } @@ -53,14 +54,15 @@ fun App( @Composable private fun VerifySessionOnResume( - navController: NavHostController, + navController: NavController, hasCompletedIntro: Boolean?, authorizationRepository: AuthorizationRepository ) { var requireAuthCheck = remember { false } LifecycleResumeEffect(hasCompletedIntro) { if (hasCompletedIntro == true && requireAuthCheck) { - enforceAuth(authorizationRepository, navController.currentDestination, navController) + // Use the top-of-stack key in Nav3 + enforceAuth(authorizationRepository, null, navController) } onPauseOrDispose { requireAuthCheck = true diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt index 8e1369f..f3a0082 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt @@ -12,10 +12,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.AppRouteMapper +import com.darkrockstudios.app.securecamera.navigation.NavController +import com.darkrockstudios.app.securecamera.navigation.rememberCompatNavController import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.firstOrNull @@ -31,7 +32,8 @@ class MainActivity : ComponentActivity() { private val locationRepository: LocationRepository by inject() private val preferences: AppPreferencesDataSource by inject() private val authorizationRepository: AuthorizationRepository by inject() - lateinit var navController: NavHostController + lateinit var navController: NavController + lateinit var backStack: androidx.navigation3.runtime.NavBackStack override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -45,10 +47,13 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() - val startDestination = determineStartRoute() + val startRoute = determineStartRoute() setContent { - navController = rememberNavController() - App(capturePhoto, startDestination, navController) + val startKey = AppRouteMapper.toKey(startRoute) ?: com.darkrockstudios.app.securecamera.navigation.Camera + val (bs, controller) = rememberCompatNavController(startKey) + backStack = bs + navController = controller + App(capturePhoto, backStack, navController) } startKeepAliveWatcher() diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/about/AboutContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/about/AboutContent.kt index 47ca8a8..4c16305 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/about/AboutContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/about/AboutContent.kt @@ -17,8 +17,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.core.net.toUri -import androidx.navigation.NavHostController import com.darkrockstudios.app.securecamera.R +import com.darkrockstudios.app.securecamera.navigation.NavController /** * About screen content @@ -26,7 +26,7 @@ import com.darkrockstudios.app.securecamera.R @OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutContent( - navController: NavHostController, + navController: NavController, modifier: Modifier = Modifier, paddingValues: PaddingValues, ) { diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationContent.kt index 1f5dd46..00044bd 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationContent.kt @@ -1,35 +1,14 @@ package com.darkrockstudios.app.securecamera.auth import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Camera -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -41,8 +20,9 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController import com.darkrockstudios.app.securecamera.R +import com.darkrockstudios.app.securecamera.navigation.NavController +import com.darkrockstudios.app.securecamera.navigation.navigateClearingBackStack import com.darkrockstudios.app.securecamera.ui.HandleUiEvents import org.koin.androidx.compose.koinViewModel @@ -119,9 +99,7 @@ fun PinVerificationContent( pin = pin, returnRoute = returnRoute, onNavigate = { - navController.navigate(it) { - popUpTo(0) { inclusive = true } - } + navController.navigateClearingBackStack(it) }, onFailure = { pin = "" } ) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/BottomCameraControls.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/BottomCameraControls.kt index 65129b0..551dc50 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/BottomCameraControls.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/BottomCameraControls.kt @@ -19,16 +19,16 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.NavController @Composable fun BottomCameraControls( modifier: Modifier = Modifier, onCapture: (() -> Unit)?, isLoading: Boolean, - navController: NavHostController, + navController: NavController, ) { val context = LocalContext.current diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraContent.kt index 5951af1..a71bff4 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraContent.kt @@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.navigation.NavHostController import com.darkrockstudios.app.securecamera.KeepScreenOnEffect +import com.darkrockstudios.app.securecamera.navigation.NavController import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState @@ -15,7 +15,7 @@ import com.google.accompanist.permissions.rememberMultiplePermissionsState @Composable internal fun CameraContent( capturePhoto: MutableState, - navController: NavHostController, + navController: NavController, modifier: Modifier, paddingValues: PaddingValues, ) { diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraControls.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraControls.kt index efd6c7b..c27ffb9 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraControls.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraControls.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController import com.ashampoo.kim.model.GpsCoordinates import com.darkrockstudios.app.securecamera.LocationRepository import com.darkrockstudios.app.securecamera.R @@ -27,6 +26,7 @@ import com.darkrockstudios.app.securecamera.RequestLocationPermission import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository import com.darkrockstudios.app.securecamera.gallery.vibrateDevice import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.NavController import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -38,13 +38,13 @@ import kotlin.uuid.ExperimentalUuidApi fun CameraControls( cameraController: CameraState, capturePhoto: MutableState, - navController: NavHostController, + navController: NavController, paddingValues: PaddingValues, ) { val scope = rememberCoroutineScope() var isFlashOn by rememberSaveable(cameraController.flashMode) { mutableStateOf(cameraController.flashMode == ImageCapture.FLASH_MODE_ON) } var isTopControlsVisible by rememberSaveable { mutableStateOf(false) } - var activeJobs by remember { mutableStateOf(mutableListOf()) } + var activeJobs by remember { mutableStateOf(listOf()) } val isLoading by remember { derivedStateOf { activeJobs.isNotEmpty() } } var isFlashing by rememberSaveable { mutableStateOf(false) } val imageSaver = koinInject() diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/NoCameraPermission.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/NoCameraPermission.kt index 56c2ef0..384673e 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/NoCameraPermission.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/NoCameraPermission.kt @@ -13,15 +13,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController import com.darkrockstudios.app.securecamera.R +import com.darkrockstudios.app.securecamera.navigation.NavController import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState @OptIn(ExperimentalPermissionsApi::class) @Composable fun NoCameraPermission( - navController: NavHostController, + navController: NavController, permissionsState: MultiplePermissionsState, ) { val context = LocalContext.current diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryContent.kt index 2b4986f..b36bc7b 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryContent.kt @@ -24,12 +24,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController import com.darkrockstudios.app.securecamera.ConfirmDeletePhotoDialog import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.camera.PhotoDef import com.darkrockstudios.app.securecamera.camera.SecureImageRepository import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.NavController import com.darkrockstudios.app.securecamera.ui.HandleUiEvents import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryTopNav.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryTopNav.kt index ee91955..4433e9b 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryTopNav.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryTopNav.kt @@ -8,9 +8,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.navigation.NavController import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.navigation.AppDestinations.createImportPhotosRoute +import com.darkrockstudios.app.securecamera.navigation.NavController @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt index 2a465fe..6340d8f 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt @@ -14,9 +14,10 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.NavController +import com.darkrockstudios.app.securecamera.navigation.navigateClearingBackStack import com.darkrockstudios.app.securecamera.ui.NotificationPermissionRationale import org.koin.androidx.compose.koinViewModel import timber.log.Timber @@ -24,7 +25,7 @@ import timber.log.Timber @Composable fun ImportPhotosContent( photosToImport: List, - navController: NavHostController, + navController: NavController, paddingValues: PaddingValues ) { val viewModel: ImportPhotosViewModel = koinViewModel() @@ -156,9 +157,7 @@ fun ImportPhotosContent( Button( modifier = Modifier.padding(16.dp), onClick = { - navController.navigate(AppDestinations.GALLERY_ROUTE) { - popUpTo(0) { inclusive = true } - } + navController.navigateClearingBackStack(AppDestinations.GALLERY_ROUTE) } ) { Text(stringResource(id = R.string.import_photos_done_button)) @@ -169,7 +168,7 @@ fun ImportPhotosContent( } @Composable -private fun CancelImportDialog(navController: NavHostController, dismiss: () -> Unit) { +private fun CancelImportDialog(navController: NavController, dismiss: () -> Unit) { val viewModel: ImportPhotosViewModel = koinViewModel() AlertDialog( @@ -181,9 +180,7 @@ private fun CancelImportDialog(navController: NavHostController, dismiss: () -> onClick = { viewModel.cancelImport() dismiss() - navController.navigate(AppDestinations.GALLERY_ROUTE) { - popUpTo(0) { inclusive = true } - } + navController.navigateClearingBackStack(AppDestinations.GALLERY_ROUTE) } ) { Text(stringResource(id = R.string.discard_button)) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosTopBar.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosTopBar.kt index 8bf8814..6bcf75f 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosTopBar.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosTopBar.kt @@ -3,19 +3,13 @@ package com.darkrockstudios.app.securecamera.import import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.navigation.NavController import com.darkrockstudios.app.securecamera.R +import com.darkrockstudios.app.securecamera.navigation.NavController @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionContent.kt index dd7740f..fbab859 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionContent.kt @@ -15,9 +15,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.NavController +import com.darkrockstudios.app.securecamera.navigation.navigateClearingBackStack import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -29,7 +30,7 @@ import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalFoundationApi::class) @Composable fun IntroductionContent( - navController: NavHostController, + navController: NavController, modifier: Modifier = Modifier, paddingValues: PaddingValues ) { @@ -40,9 +41,7 @@ fun IntroductionContent( // Navigate to camera when PIN is created LaunchedEffect(uiState.pinCreated) { if (uiState.pinCreated) { - navController.navigate(AppDestinations.CAMERA_ROUTE) { - popUpTo(0) - } + navController.navigateClearingBackStack(AppDestinations.CAMERA_ROUTE) } } @@ -64,7 +63,9 @@ fun IntroductionContent( } Box( - modifier = modifier.fillMaxSize().padding(paddingValues), + modifier = modifier + .fillMaxSize() + .padding(paddingValues), ) { HorizontalPager( state = pagerState, diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinations.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinations.kt index 5736c63..aa29570 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinations.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinations.kt @@ -1,11 +1,8 @@ package com.darkrockstudios.app.securecamera.navigation import android.net.Uri -import android.os.Bundle import android.os.Parcelable import androidx.core.net.toUri -import androidx.navigation.NavDestination -import androidx.navigation.NavType import kotlinx.parcelize.Parcelize import kotlinx.serialization.Contextual import kotlinx.serialization.KSerializer @@ -13,7 +10,6 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encodeToString import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json @@ -65,33 +61,25 @@ object AppDestinations { return "import_photos/$b64" } - fun isPinVerificationRoute(destination: NavDestination?): Boolean { - if (destination == null) return false - return destination.route?.startsWith("pin_verification/") ?: false - } - @OptIn(ExperimentalEncodingApi::class) fun encodeReturnRoute(route: String): String = Base64.UrlSafe.encode(route.toByteArray()) @OptIn(ExperimentalEncodingApi::class) fun decodeReturnRoute(encodedRoute: String): String = String(Base64.UrlSafe.decode(encodedRoute)) -} - -@OptIn(ExperimentalEncodingApi::class) -val UriListType = object : NavType(isNullableAllowed = false) { - val json: Json = getJson() - override fun get(bundle: Bundle, key: String): PhotoImportJob? { - return bundle.getParcelable(key) - } - - override fun parseValue(value: String): PhotoImportJob { - val jsonStr = String(Base64.UrlSafe.decode(value)) - return json.decodeFromString(jsonStr) - } + fun decodeRouteArg(route: String, prefix: String): String? = + route.removePrefix(prefix).takeIf { it.isNotBlank() } - override fun put(bundle: Bundle, key: String, value: PhotoImportJob) { - bundle.putParcelable(key, value) + @OptIn(ExperimentalEncodingApi::class) + fun decodeImportJob(route: String): PhotoImportJob? { + val b64 = route.substringAfter("import_photos/", missingDelimiterValue = "") + if (b64.isBlank()) return null + return try { + val jsonStr = String(Base64.UrlSafe.decode(b64)) + navJson.decodeFromString(jsonStr) + } catch (_: Throwable) { + null + } } } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppKeys.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppKeys.kt new file mode 100644 index 0000000..1714101 --- /dev/null +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppKeys.kt @@ -0,0 +1,62 @@ +package com.darkrockstudios.app.securecamera.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +// Define all destinations as typed Nav3 keys +@Serializable +object Introduction : NavKey +@Serializable +object Camera : NavKey +@Serializable +object Gallery : NavKey +@Serializable +data class ViewPhoto(val photoName: String) : NavKey +@Serializable +data class ObfuscatePhoto(val photoName: String) : NavKey +@Serializable +object Settings : NavKey +@Serializable +object About : NavKey + +// We keep returnRoute as String for compatibility with existing route builders +@Serializable +data class PinVerification(val returnRoute: String) : NavKey +@Serializable +data class ImportPhotos(val job: PhotoImportJob) : NavKey + +// Helpers to translate between legacy string routes and typed keys +object AppRouteMapper { + fun toKey(route: String): NavKey? = when { + route == AppDestinations.INTRODUCTION_ROUTE -> Introduction + route == AppDestinations.CAMERA_ROUTE -> Camera + route == AppDestinations.GALLERY_ROUTE -> Gallery + route.startsWith("viewphoto/") -> AppDestinations + .decodeRouteArg(route, "viewphoto/")?.let { ViewPhoto(it) } + + route.startsWith("obfuscatephoto/") -> AppDestinations + .decodeRouteArg(route, "obfuscatephoto/")?.let { ObfuscatePhoto(it) } + + route.startsWith("pin_verification/") -> AppDestinations + .decodeReturnRoute(route.substringAfter("pin_verification/")) + .let { PinVerification(it) } + + route.startsWith("import_photos/") -> AppDestinations + .decodeImportJob(route)?.let { ImportPhotos(it) } + + else -> null + } + + fun toRoute(key: NavKey): String = when (key) { + Introduction -> AppDestinations.INTRODUCTION_ROUTE + Camera -> AppDestinations.CAMERA_ROUTE + Gallery -> AppDestinations.GALLERY_ROUTE + is ViewPhoto -> AppDestinations.createViewPhotoRoute(key.photoName) + is ObfuscatePhoto -> AppDestinations.createObfuscatePhotoRoute(key.photoName) + Settings -> AppDestinations.SETTINGS_ROUTE + About -> AppDestinations.ABOUT_ROUTE + is PinVerification -> AppDestinations.createPinVerificationRoute(key.returnRoute) + is ImportPhotos -> AppDestinations.createImportPhotosRoute(key.job.photos) + else -> error("Unknown NavKey type: ${key::class}") + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppNavigation.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppNavigation.kt index 7e3eebd..0d57fc2 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppNavigation.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppNavigation.kt @@ -9,10 +9,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.navigation.NavDestination -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.navArgument +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entry +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.about.AboutContent import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository @@ -25,263 +26,165 @@ import com.darkrockstudios.app.securecamera.introduction.IntroductionContent import com.darkrockstudios.app.securecamera.obfuscation.ObfuscatePhotoContent import com.darkrockstudios.app.securecamera.settings.SettingsContent import com.darkrockstudios.app.securecamera.viewphoto.ViewPhotoContent -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch -import org.koin.compose.koinInject import kotlin.io.encoding.ExperimentalEncodingApi -/** - * Main navigation component for the app - */ @OptIn(ExperimentalEncodingApi::class) @Composable fun AppNavHost( - navController: NavHostController, + backStack: NavBackStack, + navController: NavController, capturePhoto: MutableState, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState, - startDestination: String = AppDestinations.CAMERA_ROUTE, paddingValues: PaddingValues, ) { - val imageManager = koinInject() - val authManager = koinInject() - + val imageManager = org.koin.compose.koinInject() + val authManager = org.koin.compose.koinInject() val scope = rememberCoroutineScope() - /** - * Continually enforce auth as the user navigates around the app - */ - LaunchedEffect(Unit) { - authManager.checkSessionValidity() + LaunchedEffect(Unit) { authManager.checkSessionValidity() } - authManager.isAuthorized - .combine(navController.currentBackStackEntryFlow) { isAuthorized, backStackEntry -> - Pair( - isAuthorized, - backStackEntry + NavDisplay( + backStack = backStack, + onBack = { if (backStack.isNotEmpty()) backStack.removeAt(backStack.lastIndex) }, + modifier = modifier, + entryProvider = entryProvider { + entry { + IntroductionContent( + navController = navController, + modifier = Modifier.fillMaxSize(), + paddingValues = paddingValues, ) } - .collect { (_, backStackEntry) -> - enforceAuth(authManager, backStackEntry.destination, navController) - } - } - - NavHost( - navController = navController, - startDestination = startDestination, - modifier = modifier - ) { - defaultAnimatedComposable( - route = AppDestinations.INTRODUCTION_ROUTE, - ) { - IntroductionContent( - navController = navController, - modifier = Modifier.fillMaxSize(), - paddingValues = paddingValues, - ) - } - - defaultAnimatedComposable( - AppDestinations.CAMERA_ROUTE, - ) { - CameraContent( - capturePhoto = capturePhoto, - navController = navController, - modifier = Modifier.fillMaxSize(), - paddingValues = paddingValues - ) - } - - defaultAnimatedComposable( - AppDestinations.GALLERY_ROUTE, - ) { - val isAuthorized by authManager.isAuthorized.collectAsState() - - if (isAuthorized) { - GalleryContent( + entry { + CameraContent( + capturePhoto = capturePhoto, navController = navController, modifier = Modifier.fillMaxSize(), - paddingValues = paddingValues, - snackbarHostState = snackbarHostState + paddingValues = paddingValues ) - } else { - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.unauthorized), - modifier = Modifier.align(Alignment.Center) - ) - } } - } - - defaultAnimatedComposable( - route = AppDestinations.VIEW_PHOTO_ROUTE, - arguments = listOf(navArgument("photoName") { defaultValue = "" }), - ) { backStackEntry -> - val photoName = backStackEntry.arguments?.getString("photoName") ?: "" - - if (authManager.checkSessionValidity()) { - val photo = imageManager.getPhotoByName(photoName) - if (photo != null) { - ViewPhotoContent( - initialPhoto = photo, + entry { + val isAuthorized by authManager.isAuthorized.collectAsState() + if (isAuthorized) { + GalleryContent( navController = navController, modifier = Modifier.fillMaxSize(), paddingValues = paddingValues, - snackbarHostState = snackbarHostState, + snackbarHostState = snackbarHostState ) } else { - Text(text = stringResource(R.string.photo_content_none_selected)) + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.unauthorized), + modifier = Modifier.align(Alignment.Center) + ) + } } - } else { - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.unauthorized), - modifier = Modifier.align(Alignment.Center) - ) + } + entry { key -> + if (authManager.checkSessionValidity()) { + val photo = imageManager.getPhotoByName(key.photoName) + if (photo != null) { + ViewPhotoContent( + initialPhoto = photo, + navController = navController, + modifier = Modifier.fillMaxSize(), + paddingValues = paddingValues, + snackbarHostState = snackbarHostState, + ) + } else { + Text(text = stringResource(R.string.photo_content_none_selected)) + } + } else { + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.unauthorized), + modifier = Modifier.align(Alignment.Center) + ) + } } } - } - - defaultAnimatedComposable( - route = AppDestinations.PIN_VERIFICATION_ROUTE, - arguments = listOf(navArgument("returnRoute") { - defaultValue = AppDestinations.encodeReturnRoute(AppDestinations.CAMERA_ROUTE) - }), - ) { backStackEntry -> - val returnRoute = backStackEntry.arguments?.getString("returnRoute")?.let { encodedRoute -> - AppDestinations.decodeReturnRoute(encodedRoute) - } ?: AppDestinations.CAMERA_ROUTE - - PinVerificationContent( - navController = navController, - returnRoute = returnRoute, - snackbarHostState = snackbarHostState, - modifier = Modifier.fillMaxSize() - ) - } - - defaultAnimatedComposable( - AppDestinations.SETTINGS_ROUTE, - ) { - SettingsContent( - navController = navController, - modifier = Modifier.fillMaxSize(), - paddingValues = paddingValues, - snackbarHostState = snackbarHostState, - ) - } - - defaultAnimatedComposable( - AppDestinations.ABOUT_ROUTE, - ) { - AboutContent( - navController = navController, - modifier = Modifier.fillMaxSize(), - paddingValues = paddingValues, - ) - } - - defaultAnimatedComposable( - route = AppDestinations.OBFUSCATE_PHOTO_ROUTE, - arguments = listOf(navArgument("photoName") { defaultValue = "" }), - ) { backStackEntry -> - val photoName = backStackEntry.arguments?.getString("photoName") ?: "" - - if (authManager.checkSessionValidity()) { - ObfuscatePhotoContent( - photoName = photoName, + entry { key -> + PinVerificationContent( navController = navController, + returnRoute = key.returnRoute, snackbarHostState = snackbarHostState, - outerScope = scope, + modifier = Modifier.fillMaxSize() + ) + } + entry { + SettingsContent( + navController = navController, + modifier = Modifier.fillMaxSize(), + paddingValues = paddingValues, + snackbarHostState = snackbarHostState, + ) + } + entry { + AboutContent( + navController = navController, + modifier = Modifier.fillMaxSize(), paddingValues = paddingValues, ) - } else { - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.unauthorized), - modifier = Modifier.align(Alignment.Center) + } + entry { key -> + if (authManager.checkSessionValidity()) { + ObfuscatePhotoContent( + photoName = key.photoName, + navController = navController, + snackbarHostState = snackbarHostState, + outerScope = scope, + paddingValues = paddingValues, ) + } else { + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.unauthorized), + modifier = Modifier.align(Alignment.Center) + ) + } } } - } - - defaultAnimatedComposable( - route = AppDestinations.IMPORT_PHOTOS_ROUTE, - arguments = listOf( - navArgument("photoUris") { - type = UriListType - } - ) - ) { backStackEntry -> - if (authManager.checkSessionValidity()) { - val importJob = backStackEntry.arguments?.getParcelable("photoUris") - if (importJob == null) { - val msg = stringResource(R.string.import_error_no_photos) - scope.launch { - snackbarHostState.showSnackbar(msg) - } - navController.navigate(AppDestinations.CAMERA_ROUTE) { - launchSingleTop = true - } - } else { + entry { key -> + if (authManager.checkSessionValidity()) { ImportPhotosContent( - photosToImport = importJob.photos, + photosToImport = key.job.photos, navController = navController, paddingValues = paddingValues, ) - } - } else { - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.unauthorized), - modifier = Modifier.align(Alignment.Center) - ) + } else { + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.unauthorized), + modifier = Modifier.align(Alignment.Center) + ) + } } } } - } + ) } -/** - * Check for session validity, send user to PinVerification - * if needed. - * - * This also calculates the return path for successful - * PinVerification. - */ fun enforceAuth( authManager: AuthorizationRepository, - destination: NavDestination?, - navController: NavHostController + currentKey: NavKey?, + navController: NavController ) { if ( - authManager.checkSessionValidity().not() - && AppDestinations.isPinVerificationRoute(destination).not() - && destination?.route != AppDestinations.INTRODUCTION_ROUTE + authManager.checkSessionValidity().not() && + currentKey !is PinVerification && + currentKey !is Introduction ) { - val returnRoute = when (destination?.route) { - AppDestinations.VIEW_PHOTO_ROUTE -> { - navController.currentBackStackEntry?.arguments?.getString("photoName") - ?.let { photoName -> - AppDestinations.createViewPhotoRoute(photoName) - } ?: AppDestinations.CAMERA_ROUTE - } - - AppDestinations.OBFUSCATE_PHOTO_ROUTE -> { - navController.currentBackStackEntry?.arguments?.getString("photoName") - ?.let { photoName -> - AppDestinations.createObfuscatePhotoRoute(photoName) - } ?: AppDestinations.CAMERA_ROUTE - } - - else -> { - destination?.route ?: AppDestinations.CAMERA_ROUTE - } - } - - navController.navigate(AppDestinations.createPinVerificationRoute(returnRoute)) { - launchSingleTop = true + val returnRoute = when (currentKey) { + is ViewPhoto -> AppDestinations.createViewPhotoRoute(currentKey.photoName) + is ObfuscatePhoto -> AppDestinations.createObfuscatePhotoRoute(currentKey.photoName) + is Gallery -> AppDestinations.GALLERY_ROUTE + is Settings -> AppDestinations.SETTINGS_ROUTE + is About -> AppDestinations.ABOUT_ROUTE + is ImportPhotos -> AppDestinations.createImportPhotosRoute(currentKey.job.photos) + else -> AppDestinations.CAMERA_ROUTE } + navController.navigate(AppDestinations.createPinVerificationRoute(returnRoute)) { launchSingleTop = true } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt new file mode 100644 index 0000000..347dbb7 --- /dev/null +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt @@ -0,0 +1,92 @@ +package com.darkrockstudios.app.securecamera.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack + +/** Minimal compatibility controller to keep most of the app code unchanged while using Navigation 3 */ +interface NavController { + fun navigate(route: String, builder: (NavOptions.() -> Unit)? = null) + fun navigate(route: String) + fun navigateUp(): Boolean + fun popBackStack(): Boolean = navigateUp() +} + +class NavOptions { + var launchSingleTop: Boolean = false +} + +class Nav3CompatController( + private val backStack: NavBackStack +) : NavController { + override fun navigate(route: String, builder: (NavOptions.() -> Unit)?) { + val opts = NavOptions().apply { builder?.invoke(this) } + val key = AppRouteMapper.toKey(route) + if (key != null) { + if (opts.launchSingleTop) { + val current = backStack.lastOrNull() + if (current != null && AppRouteMapper.toRoute(current) == route) return + } + backStack.add(key) + } + } + + override fun navigate(route: String) { + navigate(route, null) + } + + override fun navigateUp(): Boolean { + return backStack.removeLastOrNull() != null + } + + /** Clears the entire back stack. */ + fun clearBackStack() { + while (backStack.removeLastOrNull() != null) { + // keep removing + } + } +} + +/** + * Nav3-compatible helper to emulate Nav2's navigate { popUpTo(0) { inclusive = true } } + */ +fun NavController.navigateClearingBackStack(route: String, launchSingleTop: Boolean = false) { + when (this) { + is Nav3CompatController -> { + this.clearBackStack() + this.navigate(route) { this.launchSingleTop = launchSingleTop } + } + + else -> { + // Fallback: just navigate if we can't clear + this.navigate(route) { this.launchSingleTop = launchSingleTop } + } + } +} + +/** + * Emulate Nav2's navigate(target) { popUpTo(base) } — ensure back stack is [base, target]. + */ +fun NavController.navigateFromBase(baseRoute: String, targetRoute: String, launchSingleTop: Boolean = false) { + when (this) { + is Nav3CompatController -> { + this.clearBackStack() + this.navigate(baseRoute) + this.navigate(targetRoute) { this.launchSingleTop = launchSingleTop } + } + + else -> { + // Fallback: best-effort — just navigate to target + this.navigate(targetRoute) { this.launchSingleTop = launchSingleTop } + } + } +} + +@Composable +fun rememberCompatNavController(startKey: NavKey): Pair { + val backStack = rememberNavBackStack(startKey) + val controller = remember(backStack) { Nav3CompatController(backStack) } + return backStack to controller +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt index 9240bd5..75ec434 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt @@ -6,34 +6,11 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BlurOff -import androidx.compose.material.icons.filled.BlurOn -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Save -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds @@ -49,9 +26,10 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.NavController +import com.darkrockstudios.app.securecamera.navigation.navigateFromBase import com.darkrockstudios.app.securecamera.ui.HandleUiEvents import com.darkrockstudios.app.securecamera.ui.PlayfulScaleVisibility import kotlinx.coroutines.CoroutineScope @@ -452,9 +430,7 @@ fun ObfuscatePhotoContent( saveAsCopy = { viewModel.saveAsCopy( onNavigate = { route -> - navController.navigate(route) { - popUpTo(AppDestinations.GALLERY_ROUTE) - } + navController.navigateFromBase(AppDestinations.GALLERY_ROUTE, route) } ) }, diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsContent.kt index a685205..2f8f336 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsContent.kt @@ -19,10 +19,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController import com.darkrockstudios.app.securecamera.LocationPermissionStatus import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.NavController +import com.darkrockstudios.app.securecamera.navigation.navigateClearingBackStack import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource.Companion.SESSION_TIMEOUT_10_MIN import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource.Companion.SESSION_TIMEOUT_1_MIN import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource.Companion.SESSION_TIMEOUT_5_MIN @@ -37,7 +38,7 @@ import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsContent( - navController: NavHostController, + navController: NavController, modifier: Modifier = Modifier, paddingValues: PaddingValues, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, @@ -49,9 +50,7 @@ fun SettingsContent( LaunchedEffect(uiState.securityResetComplete) { if (uiState.securityResetComplete) { - navController.navigate(AppDestinations.INTRODUCTION_ROUTE) { - popUpTo(0) { inclusive = true } - } + navController.navigateClearingBackStack(AppDestinations.INTRODUCTION_ROUTE) } } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/ui/UiEventHandler.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/ui/UiEventHandler.kt index 1fc96bb..3dda9dc 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/ui/UiEventHandler.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/ui/UiEventHandler.kt @@ -3,7 +3,7 @@ package com.darkrockstudios.app.securecamera.ui import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.navigation.NavController +import com.darkrockstudios.app.securecamera.navigation.NavController import kotlinx.coroutines.flow.SharedFlow @Composable diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt index f81f0e8..7e6c6ae 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt @@ -25,11 +25,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController import com.darkrockstudios.app.securecamera.ConfirmDeletePhotoDialog import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.camera.PhotoDef import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.NavController import com.darkrockstudios.app.securecamera.ui.HandleUiEvents import net.engawapg.lib.zoomable.ExperimentalZoomableApi import net.engawapg.lib.zoomable.rememberZoomState @@ -130,7 +130,9 @@ fun ViewPhotoContent( val photo = uiState.photos[index] ViewPhoto( - modifier = Modifier.fillParentMaxSize().padding(bottom = paddingValues.calculateBottomPadding()), + modifier = Modifier + .fillParentMaxSize() + .padding(bottom = paddingValues.calculateBottomPadding()), photo = photo, viewModel = viewModel, ) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoTopBar.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoTopBar.kt index 7562c32..fcb6d07 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoTopBar.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoTopBar.kt @@ -9,8 +9,8 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.navigation.NavController import com.darkrockstudios.app.securecamera.R +import com.darkrockstudios.app.securecamera.navigation.NavController @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ee18313..b9b5328 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,10 +29,9 @@ junit = "4.13.2" junitVersion = "1.3.0" activityCompose = "1.10.1" composeBom = "2025.07.00" -navigationCompose = "2.9.3" navigation3 = "1.0.0-alpha06" nav3Material = "1.0.0-SNAPSHOT" -lifecycleViewmodel = "1.0.0-SNAPSHOT" +lifecycleViewmodel = "1.0.0-alpha04" koin-bom = "4.1.0" cryptographyBom = "0.5.0" datastorePreferences = "1.1.7" @@ -54,7 +53,6 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewModel" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewModel" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } -androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-rules = { module = "androidx.test:rules", version.ref = "rules" } androidx-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata", version.ref = "runtimeLivedata" } argon2kt = { module = "com.lambdapioneer.argon2kt:argon2kt", version.ref = "argon2kt" } diff --git a/settings.gradle.kts b/settings.gradle.kts index a77da55..dccec3c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,6 +17,8 @@ dependencyResolutionManagement { google() mavenCentral() maven { url = uri("https://jitpack.io") } + // TODO this is only needed for nav3 snapshots, can remove once they are stable + maven { url = uri("https://androidx.dev/snapshots/builds/13915848/artifacts/repository") } } } From 27626fd0571d8b81a3c3c0e86fe52b5cb92c2896 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 11 Aug 2025 23:07:48 -0700 Subject: [PATCH 3/8] Nav3 now using strongly typed keys --- .../app/securecamera/CameraVectorImages.kt | 2 +- .../app/securecamera/MainActivity.kt | 34 ++-- .../auth/PinVerificationContent.kt | 9 +- .../auth/PinVerificationViewModel.kt | 11 +- .../camera/BottomCameraControls.kt | 7 +- .../app/securecamera/camera/CameraControls.kt | 5 +- .../securecamera/gallery/GalleryContent.kt | 11 +- .../app/securecamera/gallery/GalleryTopNav.kt | 5 +- .../import/ImportPhotosContent.kt | 6 +- .../introduction/IntroductionContent.kt | 4 +- .../navigation/AppDestinations.kt | 109 +++---------- .../app/securecamera/navigation/AppKeys.kt | 62 -------- .../securecamera/navigation/AppNavigation.kt | 20 +-- .../navigation/Nav3CompatController.kt | 62 +++----- .../securecamera/navigation/NavAnimations.kt | 88 ----------- .../securecamera/navigation/PhotoImportJob.kt | 38 +++++ .../obfuscation/ObfuscatePhotoContent.kt | 4 +- .../obfuscation/ObfuscatePhotoViewModel.kt | 7 +- .../securecamera/settings/SettingsContent.kt | 7 +- .../viewphoto/ViewPhotoContent.kt | 4 +- .../navigation/AppDestinationsTest.kt | 145 ------------------ 21 files changed, 153 insertions(+), 487 deletions(-) delete mode 100644 app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppKeys.kt delete mode 100644 app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/NavAnimations.kt create mode 100644 app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/PhotoImportJob.kt delete mode 100644 app/src/test/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinationsTest.kt diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/CameraVectorImages.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/CameraVectorImages.kt index fd2e0c1..632db49 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/CameraVectorImages.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/CameraVectorImages.kt @@ -5,7 +5,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp -public val Camera: ImageVector +val Camera: ImageVector get() { if (_Camera != null) { return _Camera!! diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt index f3a0082..f1ce8d5 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt @@ -9,14 +9,15 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository -import com.darkrockstudios.app.securecamera.navigation.AppDestinations -import com.darkrockstudios.app.securecamera.navigation.AppRouteMapper -import com.darkrockstudios.app.securecamera.navigation.NavController -import com.darkrockstudios.app.securecamera.navigation.rememberCompatNavController +import com.darkrockstudios.app.securecamera.navigation.* +import com.darkrockstudios.app.securecamera.navigation.Camera import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.firstOrNull @@ -33,7 +34,6 @@ class MainActivity : ComponentActivity() { private val preferences: AppPreferencesDataSource by inject() private val authorizationRepository: AuthorizationRepository by inject() lateinit var navController: NavController - lateinit var backStack: androidx.navigation3.runtime.NavBackStack override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -47,11 +47,10 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() - val startRoute = determineStartRoute() + val startKey = determineStartKey() setContent { - val startKey = AppRouteMapper.toKey(startRoute) ?: com.darkrockstudios.app.securecamera.navigation.Camera - val (bs, controller) = rememberCompatNavController(startKey) - backStack = bs + val backStack = rememberNavBackStack(startKey) + val controller = remember(backStack) { Nav3CompatController(backStack) } navController = controller App(capturePhoto, backStack, navController) } @@ -59,24 +58,23 @@ class MainActivity : ComponentActivity() { startKeepAliveWatcher() } - private fun determineStartRoute(): String { + private fun determineStartKey(): NavKey { val photosToImport = receiveFiles() val hasCompletedIntro = runBlocking { preferences.hasCompletedIntro.firstOrNull() ?: false } - val startDestination = if (hasCompletedIntro) { - val targetDestination = if (photosToImport.isNotEmpty()) { - AppDestinations.createImportPhotosRoute(photosToImport) + return if (hasCompletedIntro) { + val targetKey: DestinationKey = if (photosToImport.isNotEmpty()) { + ImportPhotos(PhotoImportJob(photosToImport)) } else { - AppDestinations.CAMERA_ROUTE + Camera } if (authorizationRepository.checkSessionValidity()) { - targetDestination + targetKey } else { - AppDestinations.createPinVerificationRoute(targetDestination) + PinVerification(targetKey) } } else { - AppDestinations.INTRODUCTION_ROUTE + Introduction } - return startDestination } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationContent.kt index 00044bd..fcfce17 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationContent.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.navigation.NavController import com.darkrockstudios.app.securecamera.navigation.navigateClearingBackStack @@ -33,7 +34,7 @@ import org.koin.androidx.compose.koinViewModel fun PinVerificationContent( navController: NavController, snackbarHostState: SnackbarHostState, - returnRoute: String, + returnKey: NavKey, modifier: Modifier = Modifier ) { val viewModel: PinVerificationViewModel = koinViewModel() @@ -97,9 +98,9 @@ fun PinVerificationContent( fun verifyPin() { viewModel.verify( pin = pin, - returnRoute = returnRoute, - onNavigate = { - navController.navigateClearingBackStack(it) + returnKey = returnKey, + onNavigate = { destKey -> + navController.navigateClearingBackStack(destKey) }, onFailure = { pin = "" } ) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationViewModel.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationViewModel.kt index 4d6ed27..e168fd3 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationViewModel.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationViewModel.kt @@ -2,10 +2,11 @@ package com.darkrockstudios.app.securecamera.auth import android.content.Context import androidx.lifecycle.viewModelScope +import androidx.navigation3.runtime.NavKey import com.darkrockstudios.app.securecamera.BaseViewModel import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.gallery.vibrateDevice -import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.Introduction import com.darkrockstudios.app.securecamera.usecases.InvalidateSessionUseCase import com.darkrockstudios.app.securecamera.usecases.PinSizeUseCase import com.darkrockstudios.app.securecamera.usecases.SecurityResetUseCase @@ -90,7 +91,8 @@ class PinVerificationViewModel( } } - fun verify(pin: String, returnRoute: String, onNavigate: (String) -> Unit, onFailure: () -> Unit) { + + fun verify(pin: String, returnKey: NavKey, onNavigate: (NavKey) -> Unit, onFailure: () -> Unit) { val currentState = uiState.value if (pin.isBlank()) { @@ -115,8 +117,7 @@ class PinVerificationViewModel( failedAttempts = 0 ) } - - onNavigate(returnRoute) + onNavigate(returnKey) } } else { val newFailedAttempts = authRepository.incrementFailedAttempts() @@ -144,7 +145,7 @@ class PinVerificationViewModel( // Nuke it all securityResetUseCase.reset() showMessage(appContext.getString(R.string.pin_verification_all_data_deleted)) - onNavigate(AppDestinations.INTRODUCTION_ROUTE) + onNavigate(Introduction) } onFailure() diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/BottomCameraControls.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/BottomCameraControls.kt index 551dc50..48f174c 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/BottomCameraControls.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/BottomCameraControls.kt @@ -20,8 +20,9 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.darkrockstudios.app.securecamera.R -import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.Gallery import com.darkrockstudios.app.securecamera.navigation.NavController +import com.darkrockstudios.app.securecamera.navigation.Settings @Composable fun BottomCameraControls( @@ -38,7 +39,7 @@ fun BottomCameraControls( .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), ) { ElevatedButton( - onClick = { navController.navigate(AppDestinations.SETTINGS_ROUTE) }, + onClick = { navController.navigate(Settings) }, enabled = isLoading.not(), modifier = Modifier.align(Alignment.BottomStart), ) { @@ -73,7 +74,7 @@ fun BottomCameraControls( } ElevatedButton( - onClick = { navController.navigate(AppDestinations.GALLERY_ROUTE) }, + onClick = { navController.navigate(Gallery) }, enabled = isLoading.not(), modifier = Modifier.align(Alignment.BottomEnd), ) { diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraControls.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraControls.kt index c27ffb9..411dcc6 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraControls.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraControls.kt @@ -25,8 +25,9 @@ import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.RequestLocationPermission import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository import com.darkrockstudios.app.securecamera.gallery.vibrateDevice -import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.Camera import com.darkrockstudios.app.securecamera.navigation.NavController +import com.darkrockstudios.app.securecamera.navigation.PinVerification import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -86,7 +87,7 @@ fun CameraControls( } activeJobs = (activeJobs + job).toMutableList() } else { - navController.navigate(AppDestinations.createPinVerificationRoute(AppDestinations.CAMERA_ROUTE)) + navController.navigate(PinVerification(Camera)) } } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryContent.kt index b36bc7b..9e069f1 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryContent.kt @@ -28,8 +28,8 @@ import com.darkrockstudios.app.securecamera.ConfirmDeletePhotoDialog import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.camera.PhotoDef import com.darkrockstudios.app.securecamera.camera.SecureImageRepository -import com.darkrockstudios.app.securecamera.navigation.AppDestinations import com.darkrockstudios.app.securecamera.navigation.NavController +import com.darkrockstudios.app.securecamera.navigation.ViewPhoto import com.darkrockstudios.app.securecamera.ui.HandleUiEvents import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -107,7 +107,7 @@ fun GalleryContent( if (uiState.isSelectionMode) { viewModel.togglePhotoSelection(photoName) } else { - navController.navigate(AppDestinations.createViewPhotoRoute(photoName)) + navController.navigate(ViewPhoto(photoName)) } }, ) @@ -135,7 +135,12 @@ private fun PhotoGrid( val scope = rememberCoroutineScope() LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 128.dp), - contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = paddingValues.calculateBottomPadding(), top = 0.dp), + contentPadding = PaddingValues( + start = 8.dp, + end = 8.dp, + bottom = paddingValues.calculateBottomPadding(), + top = 0.dp + ), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier.fillMaxSize() diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryTopNav.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryTopNav.kt index 4433e9b..8ab005b 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryTopNav.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryTopNav.kt @@ -9,8 +9,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.darkrockstudios.app.securecamera.R -import com.darkrockstudios.app.securecamera.navigation.AppDestinations.createImportPhotosRoute +import com.darkrockstudios.app.securecamera.navigation.ImportPhotos import com.darkrockstudios.app.securecamera.navigation.NavController +import com.darkrockstudios.app.securecamera.navigation.PhotoImportJob @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -24,7 +25,7 @@ fun GalleryTopNav( onCancelSelection: () -> Unit = {} ) { val openPhotoPicker = rememberPhotoPickerLauncher { uris -> - navController.navigate(createImportPhotosRoute(uris)) + navController.navigate(ImportPhotos(PhotoImportJob(uris))) } TopAppBar( diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt index 6340d8f..23ab626 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.darkrockstudios.app.securecamera.R -import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.Gallery import com.darkrockstudios.app.securecamera.navigation.NavController import com.darkrockstudios.app.securecamera.navigation.navigateClearingBackStack import com.darkrockstudios.app.securecamera.ui.NotificationPermissionRationale @@ -157,7 +157,7 @@ fun ImportPhotosContent( Button( modifier = Modifier.padding(16.dp), onClick = { - navController.navigateClearingBackStack(AppDestinations.GALLERY_ROUTE) + navController.navigateClearingBackStack(Gallery) } ) { Text(stringResource(id = R.string.import_photos_done_button)) @@ -180,7 +180,7 @@ private fun CancelImportDialog(navController: NavController, dismiss: () -> Unit onClick = { viewModel.cancelImport() dismiss() - navController.navigateClearingBackStack(AppDestinations.GALLERY_ROUTE) + navController.navigateClearingBackStack(Gallery) } ) { Text(stringResource(id = R.string.discard_button)) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionContent.kt index fbab859..30b6f5e 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionContent.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.darkrockstudios.app.securecamera.R -import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.Camera import com.darkrockstudios.app.securecamera.navigation.NavController import com.darkrockstudios.app.securecamera.navigation.navigateClearingBackStack import kotlinx.coroutines.Dispatchers @@ -41,7 +41,7 @@ fun IntroductionContent( // Navigate to camera when PIN is created LaunchedEffect(uiState.pinCreated) { if (uiState.pinCreated) { - navController.navigateClearingBackStack(AppDestinations.CAMERA_ROUTE) + navController.navigateClearingBackStack(Camera) } } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinations.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinations.kt index aa29570..d10df31 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinations.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinations.kt @@ -1,103 +1,34 @@ package com.darkrockstudios.app.securecamera.navigation -import android.net.Uri -import android.os.Parcelable -import androidx.core.net.toUri -import kotlinx.parcelize.Parcelize -import kotlinx.serialization.Contextual -import kotlinx.serialization.KSerializer +import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.Json -import kotlinx.serialization.modules.SerializersModule -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi -private fun getJson(): Json { - val module = SerializersModule { - contextual(Uri::class, UriSerializer) - } - return Json { serializersModule = module } -} - -private val navJson = getJson() - -/** - * Navigation destinations for the app - */ -object AppDestinations { - const val INTRODUCTION_ROUTE = "introduction" - const val CAMERA_ROUTE = "camera" - const val GALLERY_ROUTE = "gallery" - const val VIEW_PHOTO_ROUTE = "viewphoto/{photoName}" - const val OBFUSCATE_PHOTO_ROUTE = "obfuscatephoto/{photoName}" - const val SETTINGS_ROUTE = "settings" - const val ABOUT_ROUTE = "about" - const val PIN_VERIFICATION_ROUTE = "pin_verification/{returnRoute}" - const val IMPORT_PHOTOS_ROUTE = "import_photos/{photoUris}" - - fun createViewPhotoRoute(photoName: String): String { - return "viewphoto/$photoName" - } - - fun createObfuscatePhotoRoute(photoName: String): String { - return "obfuscatephoto/$photoName" - } - - fun createPinVerificationRoute(returnRoute: String): String { - val encoded = encodeReturnRoute(returnRoute) - return "pin_verification/$encoded" - } +@Serializable +sealed interface DestinationKey : NavKey - @OptIn(ExperimentalEncodingApi::class) - fun createImportPhotosRoute(photos: List): String { - val job = PhotoImportJob(photos) - val json = navJson.encodeToString(job) - val b64 = Base64.UrlSafe.encode(json.toByteArray()) - return "import_photos/$b64" - } +@Serializable +object Introduction : DestinationKey - @OptIn(ExperimentalEncodingApi::class) - fun encodeReturnRoute(route: String): String = Base64.UrlSafe.encode(route.toByteArray()) +@Serializable +object Camera : DestinationKey - @OptIn(ExperimentalEncodingApi::class) - fun decodeReturnRoute(encodedRoute: String): String = String(Base64.UrlSafe.decode(encodedRoute)) +@Serializable +object Gallery : DestinationKey - fun decodeRouteArg(route: String, prefix: String): String? = - route.removePrefix(prefix).takeIf { it.isNotBlank() } +@Serializable +data class ViewPhoto(val photoName: String) : DestinationKey - @OptIn(ExperimentalEncodingApi::class) - fun decodeImportJob(route: String): PhotoImportJob? { - val b64 = route.substringAfter("import_photos/", missingDelimiterValue = "") - if (b64.isBlank()) return null - return try { - val jsonStr = String(Base64.UrlSafe.decode(b64)) - navJson.decodeFromString(jsonStr) - } catch (_: Throwable) { - null - } - } -} +@Serializable +data class ObfuscatePhoto(val photoName: String) : DestinationKey @Serializable -@Parcelize -data class PhotoImportJob( - val photos: List<@Contextual Uri> -) : Parcelable +object Settings : DestinationKey -private object UriSerializer : KSerializer { - override fun serialize(encoder: Encoder, value: Uri) { - encoder.encodeString(value.toString()) - } +@Serializable +object About : DestinationKey - override fun deserialize(decoder: Decoder): Uri { - return decoder.decodeString().toUri() - } +@Serializable +data class PinVerification(val returnKey: DestinationKey) : DestinationKey - override val descriptor: SerialDescriptor - get() = PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) -} +@Serializable +data class ImportPhotos(val job: PhotoImportJob) : DestinationKey \ No newline at end of file diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppKeys.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppKeys.kt deleted file mode 100644 index 1714101..0000000 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppKeys.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.darkrockstudios.app.securecamera.navigation - -import androidx.navigation3.runtime.NavKey -import kotlinx.serialization.Serializable - -// Define all destinations as typed Nav3 keys -@Serializable -object Introduction : NavKey -@Serializable -object Camera : NavKey -@Serializable -object Gallery : NavKey -@Serializable -data class ViewPhoto(val photoName: String) : NavKey -@Serializable -data class ObfuscatePhoto(val photoName: String) : NavKey -@Serializable -object Settings : NavKey -@Serializable -object About : NavKey - -// We keep returnRoute as String for compatibility with existing route builders -@Serializable -data class PinVerification(val returnRoute: String) : NavKey -@Serializable -data class ImportPhotos(val job: PhotoImportJob) : NavKey - -// Helpers to translate between legacy string routes and typed keys -object AppRouteMapper { - fun toKey(route: String): NavKey? = when { - route == AppDestinations.INTRODUCTION_ROUTE -> Introduction - route == AppDestinations.CAMERA_ROUTE -> Camera - route == AppDestinations.GALLERY_ROUTE -> Gallery - route.startsWith("viewphoto/") -> AppDestinations - .decodeRouteArg(route, "viewphoto/")?.let { ViewPhoto(it) } - - route.startsWith("obfuscatephoto/") -> AppDestinations - .decodeRouteArg(route, "obfuscatephoto/")?.let { ObfuscatePhoto(it) } - - route.startsWith("pin_verification/") -> AppDestinations - .decodeReturnRoute(route.substringAfter("pin_verification/")) - .let { PinVerification(it) } - - route.startsWith("import_photos/") -> AppDestinations - .decodeImportJob(route)?.let { ImportPhotos(it) } - - else -> null - } - - fun toRoute(key: NavKey): String = when (key) { - Introduction -> AppDestinations.INTRODUCTION_ROUTE - Camera -> AppDestinations.CAMERA_ROUTE - Gallery -> AppDestinations.GALLERY_ROUTE - is ViewPhoto -> AppDestinations.createViewPhotoRoute(key.photoName) - is ObfuscatePhoto -> AppDestinations.createObfuscatePhotoRoute(key.photoName) - Settings -> AppDestinations.SETTINGS_ROUTE - About -> AppDestinations.ABOUT_ROUTE - is PinVerification -> AppDestinations.createPinVerificationRoute(key.returnRoute) - is ImportPhotos -> AppDestinations.createImportPhotosRoute(key.job.photos) - else -> error("Unknown NavKey type: ${key::class}") - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppNavigation.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppNavigation.kt index 0d57fc2..dd6233f 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppNavigation.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppNavigation.kt @@ -108,7 +108,7 @@ fun AppNavHost( entry { key -> PinVerificationContent( navController = navController, - returnRoute = key.returnRoute, + returnKey = key.returnKey, snackbarHostState = snackbarHostState, modifier = Modifier.fillMaxSize() ) @@ -176,15 +176,15 @@ fun enforceAuth( currentKey !is PinVerification && currentKey !is Introduction ) { - val returnRoute = when (currentKey) { - is ViewPhoto -> AppDestinations.createViewPhotoRoute(currentKey.photoName) - is ObfuscatePhoto -> AppDestinations.createObfuscatePhotoRoute(currentKey.photoName) - is Gallery -> AppDestinations.GALLERY_ROUTE - is Settings -> AppDestinations.SETTINGS_ROUTE - is About -> AppDestinations.ABOUT_ROUTE - is ImportPhotos -> AppDestinations.createImportPhotosRoute(currentKey.job.photos) - else -> AppDestinations.CAMERA_ROUTE + val returnKey = when (currentKey) { + is ViewPhoto -> ViewPhoto(currentKey.photoName) + is ObfuscatePhoto -> ObfuscatePhoto(currentKey.photoName) + is Gallery -> Gallery + is Settings -> Settings + is About -> About + is ImportPhotos -> ImportPhotos(currentKey.job) + else -> Camera } - navController.navigate(AppDestinations.createPinVerificationRoute(returnRoute)) { launchSingleTop = true } + navController.navigate(PinVerification(returnKey)) { launchSingleTop = true } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt index 347dbb7..5a14d49 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt @@ -1,15 +1,15 @@ package com.darkrockstudios.app.securecamera.navigation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.rememberNavBackStack -/** Minimal compatibility controller to keep most of the app code unchanged while using Navigation 3 */ +/** + * Minimal compatibility controller to keep most of the app code unchanged while using Navigation 3 + **/ interface NavController { - fun navigate(route: String, builder: (NavOptions.() -> Unit)? = null) - fun navigate(route: String) + fun navigate(key: NavKey, builder: (NavOptions.() -> Unit)? = null) + fun navigate(key: NavKey) + fun navigateUp(): Boolean fun popBackStack(): Boolean = navigateUp() } @@ -21,20 +21,17 @@ class NavOptions { class Nav3CompatController( private val backStack: NavBackStack ) : NavController { - override fun navigate(route: String, builder: (NavOptions.() -> Unit)?) { + override fun navigate(key: NavKey, builder: (NavOptions.() -> Unit)?) { val opts = NavOptions().apply { builder?.invoke(this) } - val key = AppRouteMapper.toKey(route) - if (key != null) { - if (opts.launchSingleTop) { - val current = backStack.lastOrNull() - if (current != null && AppRouteMapper.toRoute(current) == route) return - } - backStack.add(key) + if (opts.launchSingleTop) { + val current = backStack.lastOrNull() + if (current != null && current == key) return } + backStack.add(key) } - override fun navigate(route: String) { - navigate(route, null) + override fun navigate(key: NavKey) { + navigate(key, null) } override fun navigateUp(): Boolean { @@ -49,44 +46,29 @@ class Nav3CompatController( } } -/** - * Nav3-compatible helper to emulate Nav2's navigate { popUpTo(0) { inclusive = true } } - */ -fun NavController.navigateClearingBackStack(route: String, launchSingleTop: Boolean = false) { + +fun NavController.navigateClearingBackStack(key: NavKey, launchSingleTop: Boolean = false) { when (this) { is Nav3CompatController -> { this.clearBackStack() - this.navigate(route) { this.launchSingleTop = launchSingleTop } + this.navigate(key) { this.launchSingleTop = launchSingleTop } } - else -> { - // Fallback: just navigate if we can't clear - this.navigate(route) { this.launchSingleTop = launchSingleTop } + this.navigate(key) { this.launchSingleTop = launchSingleTop } } } } -/** - * Emulate Nav2's navigate(target) { popUpTo(base) } — ensure back stack is [base, target]. - */ -fun NavController.navigateFromBase(baseRoute: String, targetRoute: String, launchSingleTop: Boolean = false) { + +fun NavController.navigateFromBase(baseKey: NavKey, targetKey: NavKey, launchSingleTop: Boolean = false) { when (this) { is Nav3CompatController -> { this.clearBackStack() - this.navigate(baseRoute) - this.navigate(targetRoute) { this.launchSingleTop = launchSingleTop } + this.navigate(baseKey) + this.navigate(targetKey) { this.launchSingleTop = launchSingleTop } } - else -> { - // Fallback: best-effort — just navigate to target - this.navigate(targetRoute) { this.launchSingleTop = launchSingleTop } + this.navigate(targetKey) { this.launchSingleTop = launchSingleTop } } } -} - -@Composable -fun rememberCompatNavController(startKey: NavKey): Pair { - val backStack = rememberNavBackStack(startKey) - val controller = remember(backStack) { Nav3CompatController(backStack) } - return backStack to controller } \ No newline at end of file diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/NavAnimations.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/NavAnimations.kt deleted file mode 100644 index 388a4ed..0000000 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/NavAnimations.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.darkrockstudios.app.securecamera.navigation - -import androidx.compose.animation.* -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.tween -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.IntOffset -import androidx.navigation.NamedNavArgument -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavDeepLink -import androidx.navigation.compose.composable - -private const val Duration = 240 -private val StandardEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) - -private val fSpec = tween( - durationMillis = Duration, - easing = StandardEasing -) - -private val iSpec = tween( - durationMillis = Duration, - easing = StandardEasing -) - -fun defaultEnter(): EnterTransition = - fadeIn(animationSpec = fSpec) + - scaleIn( - // start a little smaller – matches Settings/Files app - initialScale = 0.75f, - animationSpec = fSpec - ) + - slideInVertically( - // slide up ≈10 % of the height - initialOffsetY = { it / 10 }, - animationSpec = iSpec - ) - -fun defaultExit(): ExitTransition = - fadeOut(animationSpec = fSpec) + - scaleOut( - targetScale = 0.9f, // recedes slightly - animationSpec = fSpec - ) + - slideOutVertically( - targetOffsetY = { -it / 10 }, // moves up a bit - animationSpec = iSpec - ) - -fun defaultPopEnter(): EnterTransition = - fadeIn(animationSpec = fSpec) + - scaleIn( - initialScale = 0.9f, // small lift-off, then settles - animationSpec = fSpec - ) + - slideInVertically( - initialOffsetY = { -it / 10 }, // comes down slightly - animationSpec = iSpec - ) - -fun defaultPopExit(): ExitTransition = - fadeOut(animationSpec = fSpec) + - scaleOut( - targetScale = 0.75f, - animationSpec = fSpec - ) + - slideOutVertically( - targetOffsetY = { it / 10 }, // slides downward - animationSpec = iSpec - ) - -fun androidx.navigation.NavGraphBuilder.defaultAnimatedComposable( - route: String, - arguments: List = emptyList(), - deepLinks: List = emptyList(), - content: @Composable() (AnimatedContentScope.(NavBackStackEntry) -> Unit) -) { - composable( - route = route, - arguments = arguments, - deepLinks = deepLinks, - enterTransition = { defaultEnter() }, - exitTransition = { defaultExit() }, - popEnterTransition = { defaultPopEnter() }, - popExitTransition = { defaultPopExit() }, - content = content, - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/PhotoImportJob.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/PhotoImportJob.kt new file mode 100644 index 0000000..2ac1c9a --- /dev/null +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/PhotoImportJob.kt @@ -0,0 +1,38 @@ +package com.darkrockstudios.app.securecamera.navigation + +import android.net.Uri +import android.os.Parcelable +import androidx.core.net.toUri +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Typed payload for importing photos. Used inside Nav3 typed keys only. + */ +@Serializable +@Parcelize +data class PhotoImportJob( + val photos: List<@Serializable(with = UriAsStringSerializer::class) Uri> +) : Parcelable + +/** + * Serialize android.net.Uri as a String to avoid requiring a contextual module. + */ +object UriAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Uri) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Uri { + return decoder.decodeString().toUri() + } +} diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt index 75ec434..468e80a 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.darkrockstudios.app.securecamera.R -import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.Gallery import com.darkrockstudios.app.securecamera.navigation.NavController import com.darkrockstudios.app.securecamera.navigation.navigateFromBase import com.darkrockstudios.app.securecamera.ui.HandleUiEvents @@ -430,7 +430,7 @@ fun ObfuscatePhotoContent( saveAsCopy = { viewModel.saveAsCopy( onNavigate = { route -> - navController.navigateFromBase(AppDestinations.GALLERY_ROUTE, route) + navController.navigateFromBase(Gallery, route) } ) }, diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoViewModel.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoViewModel.kt index fb852bd..f9b7744 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoViewModel.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoViewModel.kt @@ -6,11 +6,12 @@ import android.graphics.Rect import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.lifecycle.viewModelScope +import androidx.navigation3.runtime.NavKey import com.darkrockstudios.app.securecamera.BaseViewModel import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.camera.PhotoDef import com.darkrockstudios.app.securecamera.camera.SecureImageRepository -import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.ViewPhoto import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -168,7 +169,7 @@ class ObfuscatePhotoViewModel( } } - fun saveAsCopy(onNavigate: (String) -> Unit) { + fun saveAsCopy(onNavigate: (NavKey) -> Unit) { val bitmap = uiState.value.obscuredBitmap ?: return uiState.value.photoDef?.let { photo -> viewModelScope.launch { @@ -180,7 +181,7 @@ class ObfuscatePhotoViewModel( Timber.i("Saved copy of image: ${newPhotoDef.photoName}") showCopySuccessMessage() - onNavigate(AppDestinations.createViewPhotoRoute(newPhotoDef.photoName)) + onNavigate(ViewPhoto(newPhotoDef.photoName)) } catch (e: Exception) { Timber.e(e, "Failed to save copy of image") showSaveErrorMessage() diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsContent.kt index 2f8f336..e9867a2 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsContent.kt @@ -21,7 +21,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.darkrockstudios.app.securecamera.LocationPermissionStatus import com.darkrockstudios.app.securecamera.R -import com.darkrockstudios.app.securecamera.navigation.AppDestinations +import com.darkrockstudios.app.securecamera.navigation.About +import com.darkrockstudios.app.securecamera.navigation.Introduction import com.darkrockstudios.app.securecamera.navigation.NavController import com.darkrockstudios.app.securecamera.navigation.navigateClearingBackStack import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource.Companion.SESSION_TIMEOUT_10_MIN @@ -50,7 +51,7 @@ fun SettingsContent( LaunchedEffect(uiState.securityResetComplete) { if (uiState.securityResetComplete) { - navController.navigateClearingBackStack(AppDestinations.INTRODUCTION_ROUTE) + navController.navigateClearingBackStack(Introduction) } } @@ -88,7 +89,7 @@ fun SettingsContent( }, actions = { IconButton( - onClick = { navController.navigate(AppDestinations.ABOUT_ROUTE) }, + onClick = { navController.navigate(About) }, modifier = Modifier.padding(8.dp) ) { Icon( diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt index 7e6c6ae..227da2b 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt @@ -28,8 +28,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.darkrockstudios.app.securecamera.ConfirmDeletePhotoDialog import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.camera.PhotoDef -import com.darkrockstudios.app.securecamera.navigation.AppDestinations import com.darkrockstudios.app.securecamera.navigation.NavController +import com.darkrockstudios.app.securecamera.navigation.ObfuscatePhoto import com.darkrockstudios.app.securecamera.ui.HandleUiEvents import net.engawapg.lib.zoomable.ExperimentalZoomableApi import net.engawapg.lib.zoomable.rememberZoomState @@ -78,7 +78,7 @@ fun ViewPhotoContent( onObfuscateClick = { val currentPhoto = viewModel.getCurrentPhoto() currentPhoto?.let { - navController.navigate(AppDestinations.createObfuscatePhotoRoute(it.photoName)) + navController.navigate(ObfuscatePhoto(it.photoName)) } }, onShareClick = { diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinationsTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinationsTest.kt deleted file mode 100644 index cc0bea4..0000000 --- a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinationsTest.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.darkrockstudios.app.securecamera.navigation - -import androidx.navigation.NavDestination -import com.darkrockstudios.app.securecamera.navigation.AppDestinations.decodeReturnRoute -import com.darkrockstudios.app.securecamera.navigation.AppDestinations.encodeReturnRoute -import com.darkrockstudios.app.securecamera.navigation.AppDestinations.isPinVerificationRoute -import io.mockk.every -import io.mockk.mockk -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class AppDestinationsTest { - - @Test - fun `encodeReturnRoute should encode simple string`() { - // Given - val route = "camera" - - // When - val encoded = encodeReturnRoute(route) - - // Then - assertEquals("Y2FtZXJh", encoded) - } - - @Test - fun `encodeReturnRoute should encode empty string`() { - // Given - val route = "" - - // When - val encoded = encodeReturnRoute(route) - - // Then - assertEquals("", encoded) - } - - @Test - fun `encodeReturnRoute should encode string with special characters`() { - // Given - val route = "view_photo/image?with=special&chars" - - // When - val encoded = encodeReturnRoute(route) - - // Then - assertEquals("dmlld19waG90by9pbWFnZT93aXRoPXNwZWNpYWwmY2hhcnM=", encoded) - } - - @Test - fun `decodeReturnRoute should decode encoded simple string`() { - // Given - val encoded = "Y2FtZXJh" - - // When - val decoded = decodeReturnRoute(encoded) - - // Then - assertEquals("camera", decoded) - } - - @Test - fun `decodeReturnRoute should decode encoded empty string`() { - // Given - val encoded = "" - - // When - val decoded = decodeReturnRoute(encoded) - - // Then - assertEquals("", decoded) - } - - @Test - fun `decodeReturnRoute should decode encoded string with special characters`() { - // Given - val encoded = "dmlld19waG90by9pbWFnZT93aXRoPXNwZWNpYWwmY2hhcnM=" - - // When - val decoded = decodeReturnRoute(encoded) - - // Then - assertEquals("view_photo/image?with=special&chars", decoded) - } - - @Test - fun `encode and decode should be reversible`() { - // Given - val originalRoutes = listOf( - "camera", - "", - "view_photo/image?with=special&chars", - "settings/advanced#section", - "gallery/folder/subfolder" - ) - - // When & Then - originalRoutes.forEach { route -> - val encoded = encodeReturnRoute(route) - val decoded = decodeReturnRoute(encoded) - assertEquals(route, decoded, "Failed for route: $route") - } - } - - @Test - fun `isPinVerificationRoute should return true for pin verification route`() { - // Given - val navDestination = mockk() - every { navDestination.route } returns "pin_verification/Y2FtZXJh" - - // When - val result = isPinVerificationRoute(navDestination) - - // Then - assertTrue(result) - } - - @Test - fun `isPinVerificationRoute should return false for non-pin verification route`() { - // Given - val navDestination = mockk() - every { navDestination.route } returns "camera" - - // When - val result = isPinVerificationRoute(navDestination) - - // Then - assertFalse(result) - } - - @Test - fun `isPinVerificationRoute should return false for null route`() { - // Given - val navDestination = mockk() - every { navDestination.route } returns null - - // When - val result = isPinVerificationRoute(navDestination) - - // Then - assertFalse(result) - } -} From a9ce495cc4d77043d7984987425ebd78734efecc Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 11 Aug 2025 23:34:34 -0700 Subject: [PATCH 4/8] Fix our nav backstack bug!! --- .../import/ImportPhotosContent.kt | 7 ++-- .../navigation/Nav3CompatController.kt | 41 +++++++++++++++---- .../obfuscation/ObfuscatePhotoContent.kt | 6 +-- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt index 23ab626..4721368 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt @@ -15,9 +15,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.darkrockstudios.app.securecamera.R +import com.darkrockstudios.app.securecamera.navigation.Camera import com.darkrockstudios.app.securecamera.navigation.Gallery import com.darkrockstudios.app.securecamera.navigation.NavController -import com.darkrockstudios.app.securecamera.navigation.navigateClearingBackStack +import com.darkrockstudios.app.securecamera.navigation.navigateFromBase import com.darkrockstudios.app.securecamera.ui.NotificationPermissionRationale import org.koin.androidx.compose.koinViewModel import timber.log.Timber @@ -157,7 +158,7 @@ fun ImportPhotosContent( Button( modifier = Modifier.padding(16.dp), onClick = { - navController.navigateClearingBackStack(Gallery) + navController.navigateFromBase(Camera, Gallery) } ) { Text(stringResource(id = R.string.import_photos_done_button)) @@ -180,7 +181,7 @@ private fun CancelImportDialog(navController: NavController, dismiss: () -> Unit onClick = { viewModel.cancelImport() dismiss() - navController.navigateClearingBackStack(Gallery) + navController.navigateFromBase(Camera, Gallery) } ) { Text(stringResource(id = R.string.discard_button)) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt index 5a14d49..9348ec3 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt @@ -44,31 +44,54 @@ class Nav3CompatController( // keep removing } } -} + /** Ensures the given key is the base of the stack. Clears if different or empty. */ + fun ensureBase(base: NavKey) { + val currentBase = backStack.firstOrNull() + if (currentBase == base) return + clearBackStack() + backStack.add(base) + } +} fun NavController.navigateClearingBackStack(key: NavKey, launchSingleTop: Boolean = false) { when (this) { is Nav3CompatController -> { - this.clearBackStack() - this.navigate(key) { this.launchSingleTop = launchSingleTop } + clearBackStack() + navigate(key) { this.launchSingleTop = launchSingleTop } } else -> { - this.navigate(key) { this.launchSingleTop = launchSingleTop } + navigate(key) { this.launchSingleTop = launchSingleTop } } } } - +/** + * Puts `baseKey` at the bottom of the stack, and then navigates to targetKey + */ fun NavController.navigateFromBase(baseKey: NavKey, targetKey: NavKey, launchSingleTop: Boolean = false) { when (this) { is Nav3CompatController -> { - this.clearBackStack() - this.navigate(baseKey) - this.navigate(targetKey) { this.launchSingleTop = launchSingleTop } + ensureBase(baseKey) + navigate(targetKey) { this.launchSingleTop = launchSingleTop } + } + else -> { + navigate(targetKey) { this.launchSingleTop = launchSingleTop } + } + } +} + +fun NavController.popAndNavigate(popN: Int = 1, targetKey: NavKey, launchSingleTop: Boolean = false) { + when (this) { + is Nav3CompatController -> { + repeat(popN) { + popBackStack() + } + navigate(targetKey) { this.launchSingleTop = launchSingleTop } } + else -> { - this.navigate(targetKey) { this.launchSingleTop = launchSingleTop } + navigate(targetKey) { this.launchSingleTop = launchSingleTop } } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt index 468e80a..269258c 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt @@ -27,9 +27,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.darkrockstudios.app.securecamera.R -import com.darkrockstudios.app.securecamera.navigation.Gallery import com.darkrockstudios.app.securecamera.navigation.NavController -import com.darkrockstudios.app.securecamera.navigation.navigateFromBase +import com.darkrockstudios.app.securecamera.navigation.popAndNavigate import com.darkrockstudios.app.securecamera.ui.HandleUiEvents import com.darkrockstudios.app.securecamera.ui.PlayfulScaleVisibility import kotlinx.coroutines.CoroutineScope @@ -430,7 +429,8 @@ fun ObfuscatePhotoContent( saveAsCopy = { viewModel.saveAsCopy( onNavigate = { route -> - navController.navigateFromBase(Gallery, route) + // Pop the editor, and the original PhotoView + navController.popAndNavigate(2, route) } ) }, From 4cabf3a0f091f4aecb83f553067c1e88f686be77 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 11 Aug 2025 23:53:31 -0700 Subject: [PATCH 5/8] Fix initial photo --- .../app/securecamera/viewphoto/ViewPhotoContent.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt index 227da2b..1653231 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt @@ -8,8 +8,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.snapping.SnapPosition import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState @@ -106,7 +106,8 @@ fun ViewPhotoContent( } if (uiState.photos.isNotEmpty()) { - val listState = rememberLazyListState(initialFirstVisibleItemIndex = uiState.initialIndex) + val listState = + remember(uiState.initialIndex) { LazyListState(firstVisibleItemIndex = uiState.initialIndex) } LaunchedEffect(listState) { snapshotFlow { From ffebf85e0e2f3035fd88f8413fdc1dfc9ea6a592 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 17 Aug 2025 18:11:44 -0700 Subject: [PATCH 6/8] Fix ViewPhotoViewModel initial photo This was being reset and recreated a lot --- .../securecamera/viewphoto/ViewPhotoContent.kt | 7 ++----- .../securecamera/viewphoto/ViewPhotoViewModel.kt | 16 ++++++++-------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt index 1653231..b4813d1 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt @@ -47,13 +47,10 @@ fun ViewPhotoContent( snackbarHostState: SnackbarHostState, paddingValues: PaddingValues ) { - val viewModel: ViewPhotoViewModel = koinViewModel { parametersOf() } + val viewModel: ViewPhotoViewModel = + koinViewModel(key = initialPhoto.photoName) { parametersOf(initialPhoto.photoName) } val context = LocalContext.current - LaunchedEffect(initialPhoto) { - viewModel.initialize(initialPhoto) - } - val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(uiState.photoDeleted) { diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoViewModel.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoViewModel.kt index eec4014..68becc0 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoViewModel.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoViewModel.kt @@ -21,6 +21,7 @@ class ViewPhotoViewModel( private val imageManager: SecureImageRepository, private val preferencesManager: AppPreferencesDataSource, private val pinRepository: PinRepository, + private val initialPhotoName: String, ) : BaseViewModel() { var currentIndex: Int = 0 @@ -28,11 +29,12 @@ class ViewPhotoViewModel( override fun createState() = ViewPhotoUiState() - fun initialize(initialPhoto: PhotoDef) { + init { val photos = imageManager.getPhotos().sortedByDescending { photoDef -> photoDef.dateTaken() } - val initialIndex = photos.indexOfFirst { it == initialPhoto } + val initialIndex = photos.indexOfFirst { it.photoName == initialPhotoName } + val initialPhoto = photos[initialIndex] viewModelScope.launch { val hasPoisonPill = pinRepository.hasPoisonPillPin() @@ -48,12 +50,6 @@ class ViewPhotoViewModel( } } - viewModelScope.launch { - preferencesManager.sanitizeFileName.collect { sanitizeFileName -> - _uiState.update { it.copy(sanitizeFileName = sanitizeFileName) } - } - } - viewModelScope.launch { preferencesManager.sanitizeMetadata.collect { sanitizeMetadata -> _uiState.update { it.copy(sanitizeMetadata = sanitizeMetadata) } @@ -67,6 +63,10 @@ class ViewPhotoViewModel( fun setCurrentPhotoIndex(index: Int) { currentIndex = index + viewModelScope.launch { + val isDecoy = getCurrentPhoto()?.let { imageManager.isDecoyPhoto(it) } ?: false + _uiState.update { it.copy(isDecoy = isDecoy) } + } } fun getCurrentPhoto(): PhotoDef? { From e69d23df2f09c000a2508ba6f9838164fc05b913 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 17 Aug 2025 19:03:22 -0700 Subject: [PATCH 7/8] Simplified initialIndex VS currentIndex --- .../securecamera/obfuscation/ObfuscatePhotoContent.kt | 4 +--- .../app/securecamera/viewphoto/ViewPhotoContent.kt | 5 ++--- .../app/securecamera/viewphoto/ViewPhotoViewModel.kt | 11 +++++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt index 269258c..36b9f5a 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoContent.kt @@ -45,7 +45,7 @@ fun ObfuscatePhotoContent( outerScope: CoroutineScope, paddingValues: PaddingValues ) { - val viewModel: ObfuscatePhotoViewModel = koinViewModel() + val viewModel: ObfuscatePhotoViewModel = koinViewModel(key = photoName) val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(photoName) { @@ -60,10 +60,8 @@ fun ObfuscatePhotoContent( uiState.isCreatingRegion || uiState.currentRegion != null - // State for discard confirmation dialog val showDiscardDialog = remember { mutableStateOf(false) } - // Handle system back button BackHandler(enabled = hasUnsavedChanges) { showDiscardDialog.value = true } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt index b4813d1..e38b1aa 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt @@ -103,15 +103,14 @@ fun ViewPhotoContent( } if (uiState.photos.isNotEmpty()) { - val listState = - remember(uiState.initialIndex) { LazyListState(firstVisibleItemIndex = uiState.initialIndex) } + val listState = remember { LazyListState(firstVisibleItemIndex = uiState.currentIndex) } LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }.collect { (idx, off) -> - if (listState.firstVisibleItemIndex != viewModel.currentIndex) { + if (listState.firstVisibleItemIndex != uiState.currentIndex) { viewModel.setCurrentPhotoIndex(listState.firstVisibleItemIndex) } } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoViewModel.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoViewModel.kt index 68becc0..289839b 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoViewModel.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoViewModel.kt @@ -24,8 +24,11 @@ class ViewPhotoViewModel( private val initialPhotoName: String, ) : BaseViewModel() { - var currentIndex: Int = 0 - private set + private var currentIndex: Int + get() = uiState.value.currentIndex + set(value) { + _uiState.update { it.copy(currentIndex = value) } + } override fun createState() = ViewPhotoUiState() @@ -43,7 +46,7 @@ class ViewPhotoViewModel( _uiState.update { it.copy( photos = photos, - initialIndex = initialIndex, + currentIndex = initialIndex, hasPoisonPill = hasPoisonPill, isDecoy = isDecoy ) @@ -155,7 +158,7 @@ class ViewPhotoViewModel( data class ViewPhotoUiState( val photos: List = emptyList(), - val initialIndex: Int = 0, + val currentIndex: Int = 0, val hasPoisonPill: Boolean = false, val isDecoy: Boolean = false, val isDecoyLoading: Boolean = false, From f8f4e7b0bf18fa4ff986870beafe5bee4f50eb8e Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 17 Aug 2025 19:15:05 -0700 Subject: [PATCH 8/8] Make Import Photos more permissive --- app/src/main/AndroidManifest.xml | 4 ++-- .../com/darkrockstudios/app/securecamera/MainActivity.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 129fa3e..e4f8078 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -39,14 +39,14 @@ - + - + diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt index f1ce8d5..19a5e09 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt @@ -120,13 +120,13 @@ class MainActivity : ComponentActivity() { val intent = getIntent() return if (Intent.ACTION_SEND == intent.action && intent.type != null) { - if (intent.type?.startsWith("image/jpeg") == true) { + if (intent.type?.startsWith("image/") == true) { handleSingleImage(intent) } else { emptyList() } } else if (Intent.ACTION_SEND_MULTIPLE == intent.action && intent.type != null) { - if (intent.type?.startsWith("image/jpeg") == true) { + if (intent.type?.startsWith("image/") == true) { handleMultipleImages(intent) } else { emptyList()