diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2adea3d..919e03f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -92,7 +92,10 @@ 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)
+ implementation(libs.androidx.material3.navigation3)
implementation(libs.timber)
implementation(libs.kim)
implementation(project.dependencies.platform(libs.koin.bom))
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/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/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 8e1369f..19a5e09 100644
--- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt
+++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt
@@ -9,13 +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.navigation.NavHostController
-import androidx.navigation.compose.rememberNavController
+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.*
+import com.darkrockstudios.app.securecamera.navigation.Camera
import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.firstOrNull
@@ -31,7 +33,7 @@ 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
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -45,33 +47,34 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
- val startDestination = determineStartRoute()
+ val startKey = determineStartKey()
setContent {
- navController = rememberNavController()
- App(capturePhoto, startDestination, navController)
+ val backStack = rememberNavBackStack(startKey)
+ val controller = remember(backStack) { Nav3CompatController(backStack) }
+ navController = controller
+ App(capturePhoto, backStack, navController)
}
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 {
@@ -117,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()
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..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
@@ -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,10 @@ 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 androidx.navigation3.runtime.NavKey
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
@@ -53,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()
@@ -117,11 +98,9 @@ fun PinVerificationContent(
fun verifyPin() {
viewModel.verify(
pin = pin,
- returnRoute = returnRoute,
- onNavigate = {
- navController.navigate(it) {
- popUpTo(0) { inclusive = true }
- }
+ 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 65129b0..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
@@ -19,16 +19,17 @@ 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.Gallery
+import com.darkrockstudios.app.securecamera.navigation.NavController
+import com.darkrockstudios.app.securecamera.navigation.Settings
@Composable
fun BottomCameraControls(
modifier: Modifier = Modifier,
onCapture: (() -> Unit)?,
isLoading: Boolean,
- navController: NavHostController,
+ navController: NavController,
) {
val context = LocalContext.current
@@ -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/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..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
@@ -19,14 +19,15 @@ 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
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
@@ -38,13 +39,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()
@@ -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/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..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
@@ -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.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 ee91955..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
@@ -8,9 +8,10 @@ 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.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 2a465fe..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
@@ -14,9 +14,11 @@ 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.Camera
+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.NotificationPermissionRationale
import org.koin.androidx.compose.koinViewModel
import timber.log.Timber
@@ -24,7 +26,7 @@ import timber.log.Timber
@Composable
fun ImportPhotosContent(
photosToImport: List,
- navController: NavHostController,
+ navController: NavController,
paddingValues: PaddingValues
) {
val viewModel: ImportPhotosViewModel = koinViewModel()
@@ -156,9 +158,7 @@ fun ImportPhotosContent(
Button(
modifier = Modifier.padding(16.dp),
onClick = {
- navController.navigate(AppDestinations.GALLERY_ROUTE) {
- popUpTo(0) { inclusive = true }
- }
+ navController.navigateFromBase(Camera, Gallery)
}
) {
Text(stringResource(id = R.string.import_photos_done_button))
@@ -169,7 +169,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 +181,7 @@ private fun CancelImportDialog(navController: NavHostController, dismiss: () ->
onClick = {
viewModel.cancelImport()
dismiss()
- navController.navigate(AppDestinations.GALLERY_ROUTE) {
- popUpTo(0) { inclusive = true }
- }
+ navController.navigateFromBase(Camera, Gallery)
}
) {
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..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
@@ -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.Camera
+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(Camera)
}
}
@@ -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..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,115 +1,34 @@
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
+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.encodeToString
-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"
- }
-
- @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"
- }
-
- 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())
+@Serializable
+sealed interface DestinationKey : NavKey
- @OptIn(ExperimentalEncodingApi::class)
- fun decodeReturnRoute(encodedRoute: String): String = String(Base64.UrlSafe.decode(encodedRoute))
-}
+@Serializable
+object Introduction : DestinationKey
-@OptIn(ExperimentalEncodingApi::class)
-val UriListType = object : NavType(isNullableAllowed = false) {
- val json: Json = getJson()
+@Serializable
+object Camera : DestinationKey
- override fun get(bundle: Bundle, key: String): PhotoImportJob? {
- return bundle.getParcelable(key)
- }
+@Serializable
+object Gallery : DestinationKey
- override fun parseValue(value: String): PhotoImportJob {
- val jsonStr = String(Base64.UrlSafe.decode(value))
- return json.decodeFromString(jsonStr)
- }
+@Serializable
+data class ViewPhoto(val photoName: String) : DestinationKey
- override fun put(bundle: Bundle, key: String, value: PhotoImportJob) {
- bundle.putParcelable(key, value)
- }
-}
+@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/AppNavigation.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppNavigation.kt
index 7e3eebd..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
@@ -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,
+ returnKey = key.returnKey,
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 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(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
new file mode 100644
index 0000000..9348ec3
--- /dev/null
+++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/Nav3CompatController.kt
@@ -0,0 +1,97 @@
+package com.darkrockstudios.app.securecamera.navigation
+
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+
+/**
+ * Minimal compatibility controller to keep most of the app code unchanged while using Navigation 3
+ **/
+interface NavController {
+ fun navigate(key: NavKey, builder: (NavOptions.() -> Unit)? = null)
+ fun navigate(key: NavKey)
+
+ fun navigateUp(): Boolean
+ fun popBackStack(): Boolean = navigateUp()
+}
+
+class NavOptions {
+ var launchSingleTop: Boolean = false
+}
+
+class Nav3CompatController(
+ private val backStack: NavBackStack
+) : NavController {
+ override fun navigate(key: NavKey, builder: (NavOptions.() -> Unit)?) {
+ val opts = NavOptions().apply { builder?.invoke(this) }
+ if (opts.launchSingleTop) {
+ val current = backStack.lastOrNull()
+ if (current != null && current == key) return
+ }
+ backStack.add(key)
+ }
+
+ override fun navigate(key: NavKey) {
+ navigate(key, null)
+ }
+
+ override fun navigateUp(): Boolean {
+ return backStack.removeLastOrNull() != null
+ }
+
+ /** Clears the entire back stack. */
+ fun clearBackStack() {
+ while (backStack.removeLastOrNull() != null) {
+ // 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 -> {
+ clearBackStack()
+ navigate(key) { this.launchSingleTop = launchSingleTop }
+ }
+ else -> {
+ 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 -> {
+ 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 -> {
+ navigate(targetKey) { this.launchSingleTop = launchSingleTop }
+ }
+ }
+}
\ 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 9240bd5..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
@@ -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,9 @@ 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.popAndNavigate
import com.darkrockstudios.app.securecamera.ui.HandleUiEvents
import com.darkrockstudios.app.securecamera.ui.PlayfulScaleVisibility
import kotlinx.coroutines.CoroutineScope
@@ -68,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) {
@@ -83,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
}
@@ -452,9 +427,8 @@ fun ObfuscatePhotoContent(
saveAsCopy = {
viewModel.saveAsCopy(
onNavigate = { route ->
- navController.navigate(route) {
- popUpTo(AppDestinations.GALLERY_ROUTE)
- }
+ // Pop the editor, and the original PhotoView
+ navController.popAndNavigate(2, 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 a685205..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
@@ -19,10 +19,12 @@ 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.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
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 +39,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 +51,7 @@ fun SettingsContent(
LaunchedEffect(uiState.securityResetComplete) {
if (uiState.securityResetComplete) {
- navController.navigate(AppDestinations.INTRODUCTION_ROUTE) {
- popUpTo(0) { inclusive = true }
- }
+ navController.navigateClearingBackStack(Introduction)
}
}
@@ -89,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/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..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
@@ -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
@@ -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.navigation.ObfuscatePhoto
import com.darkrockstudios.app.securecamera.ui.HandleUiEvents
import net.engawapg.lib.zoomable.ExperimentalZoomableApi
import net.engawapg.lib.zoomable.rememberZoomState
@@ -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) {
@@ -78,7 +75,7 @@ fun ViewPhotoContent(
onObfuscateClick = {
val currentPhoto = viewModel.getCurrentPhoto()
currentPhoto?.let {
- navController.navigate(AppDestinations.createObfuscatePhotoRoute(it.photoName))
+ navController.navigate(ObfuscatePhoto(it.photoName))
}
},
onShareClick = {
@@ -106,14 +103,14 @@ fun ViewPhotoContent(
}
if (uiState.photos.isNotEmpty()) {
- val listState = rememberLazyListState(initialFirstVisibleItemIndex = 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)
}
}
@@ -130,7 +127,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/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..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
@@ -21,18 +21,23 @@ class ViewPhotoViewModel(
private val imageManager: SecureImageRepository,
private val preferencesManager: AppPreferencesDataSource,
private val pinRepository: PinRepository,
+ 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()
- 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()
@@ -41,19 +46,13 @@ class ViewPhotoViewModel(
_uiState.update {
it.copy(
photos = photos,
- initialIndex = initialIndex,
+ currentIndex = initialIndex,
hasPoisonPill = hasPoisonPill,
isDecoy = isDecoy
)
}
}
- 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 +66,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? {
@@ -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,
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)
- }
-}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5796bba..b9b5328 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -29,7 +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-alpha04"
koin-bom = "4.1.0"
cryptographyBom = "0.5.0"
datastorePreferences = "1.1.7"
@@ -51,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" }
@@ -93,6 +94,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" }
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") }
}
}