From 0ea53d9212654d35f284e085ac16b24c730afde2 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 26 Jan 2026 21:09:43 -0800 Subject: [PATCH 01/47] Fix gallery not always showing the right item once it's clicked --- .../app/securecamera/viewphoto/ViewPhotoViewModel.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 10ac059..3551f38 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 @@ -39,14 +39,20 @@ class ViewPhotoViewModel( val initialIndex = mediaItems.indexOfFirst { it.mediaName == initialMediaName } val initialMedia = mediaItems.getOrNull(initialIndex) + // Set mediaItems and currentIndex so they're available immediately + _uiState.update { + it.copy( + mediaItems = mediaItems, + currentIndex = if (initialIndex >= 0) initialIndex else 0, + ) + } + viewModelScope.launch { val hasPoisonPill = pinRepository.hasPoisonPillPin() val isDecoy = (initialMedia as? PhotoDef)?.let { imageManager.isDecoyPhoto(it) } ?: false _uiState.update { it.copy( - mediaItems = mediaItems, - currentIndex = if (initialIndex >= 0) initialIndex else 0, hasPoisonPill = hasPoisonPill, isDecoy = isDecoy ) From bdaf129c04dac823c8bbc0c98b5572a6f0314675 Mon Sep 17 00:00:00 2001 From: Wavesonics Date: Mon, 26 Jan 2026 21:49:25 -0800 Subject: [PATCH 02/47] Sanatize photo metadata strings --- app/src/main/res/values-de-rDE/strings.xml | 2 +- app/src/main/res/values-en-rUS/strings.xml | 2 +- app/src/main/res/values-es-rES/strings.xml | 2 +- app/src/main/res/values-fr-rFR/strings.xml | 2 +- app/src/main/res/values-it-rIT/strings.xml | 2 +- app/src/main/res/values-uk-rUA/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index e0478b5..ff12d3b 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -119,7 +119,7 @@ Entfernen Sie Identifikationsinformationen aus Dateinamen beim Teilen von Fotos - Metadaten säubern + Foto-Metadaten säubern Metadaten entfernen (Standort, Geräteinformationen, etc.) beim Teilen von Fotos diff --git a/app/src/main/res/values-en-rUS/strings.xml b/app/src/main/res/values-en-rUS/strings.xml index 700a5c6..9b00d38 100644 --- a/app/src/main/res/values-en-rUS/strings.xml +++ b/app/src/main/res/values-en-rUS/strings.xml @@ -119,7 +119,7 @@ Remove identifying information from file names when sharing photos - Sanitize metadata + Sanitize photo metadata Strip metadata (location, device info, etc.) when sharing photos diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index f154ff3..8b50201 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -119,7 +119,7 @@ Remove identifying information from file names when sharing photos - Anular metadatos + Anular metadatos de fotos Strip metadata (location, device info, etc.) when sharing photos diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index f9eb1dc..9eab7ce 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -119,7 +119,7 @@ Supprimer les informations d\'identification des noms de fichiers lors du partage de photos - Nettoyer les métadonnées + Nettoyer les métadonnées des photos Retirer les métadonnées (emplacement, infos de l\'appareil, etc.) lors du partage de photos diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index b9d1ee4..2b9fc69 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -119,7 +119,7 @@ Rimuovere le informazioni identificative dai nomi dei file quando si condividono le foto - Sanitizza i metadati + Sanitizza i metadati delle foto Striscia i metadati (posizione, informazioni sul dispositivo, ecc.) quando si condividono le foto diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 9fb30a7..7eac7b1 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -119,7 +119,7 @@ Вилучення інформації з імен файлів при спільному використанні фотографій - Сантизувати метадані + Сантизувати метадані фото Strip metadata (location, device info, etc.) when sharing photos diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d70cb88..5e67df0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -119,7 +119,7 @@ Remove identifying information from file names when sharing photos - 卫生化元数据 + 卫生化照片元数据 Strip metadata (location, device info, etc.) when sharing photos diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be6c094..c81d7c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -160,7 +160,7 @@ Remove identifying information from file names when sharing photos - Sanitize metadata + Sanitize photo metadata Strip metadata (location, device info, etc.) when sharing photos From 4ac9873fcc9d7fd1cecdfa569d5540fa9173e238 Mon Sep 17 00:00:00 2001 From: Wavesonics Date: Mon, 26 Jan 2026 22:31:55 -0800 Subject: [PATCH 03/47] Add some info about video security --- .../securecamera/settings/SettingsContent.kt | 219 +++++++++++++++++- .../settings/SettingsViewModel.kt | 9 + app/src/main/res/values/strings.xml | 16 ++ 3 files changed, 240 insertions(+), 4 deletions(-) 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 c5cbc78..e8d8017 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 @@ -9,6 +9,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowRight import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* @@ -18,14 +20,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey import com.darkrockstudios.app.securecamera.LocationPermissionStatus import com.darkrockstudios.app.securecamera.R -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.navigation.* import com.darkrockstudios.app.securecamera.preferences.PreferencesAppSettingsDataSource.Companion.SESSION_TIMEOUT_10_MIN import com.darkrockstudios.app.securecamera.preferences.PreferencesAppSettingsDataSource.Companion.SESSION_TIMEOUT_1_MIN import com.darkrockstudios.app.securecamera.preferences.PreferencesAppSettingsDataSource.Companion.SESSION_TIMEOUT_5_MIN @@ -290,6 +291,93 @@ fun SettingsContent( Spacer(modifier = Modifier.height(16.dp)) + // Media Security row + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 8.dp) + .clickable { viewModel.showMediaSecurityDialog() }, + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(id = R.string.settings_media_security), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(id = R.string.settings_media_security_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(12.dp)) + // Photos rating + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.PhotoCamera, + contentDescription = null, + tint = Color.Green, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(id = R.string.media_security_photos_title), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.width(60.dp) + ) + LinearProgressIndicator( + progress = { 1.0f }, + modifier = Modifier + .weight(1f) + .height(8.dp), + color = Color.Green, + ) + Text( + text = stringResource(id = R.string.media_security_photos_rating), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + // Videos rating + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Videocam, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(id = R.string.media_security_videos_title), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.width(60.dp) + ) + LinearProgressIndicator( + progress = { 0.9f }, + modifier = Modifier + .weight(1f) + .height(8.dp), + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(id = R.string.media_security_videos_rating), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + // Session timeout dropdown Row( modifier = Modifier @@ -492,5 +580,128 @@ fun SettingsContent( ) } + if (uiState.showMediaSecurityDialog) { + MediaSecurityDialog( + onDismiss = { viewModel.dismissMediaSecurityDialog() } + ) + } + HandleUiEvents(viewModel.events, snackbarHostState, navController) } + +@Composable +private fun MediaSecurityDialog( + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(id = R.string.media_security_dialog_title), + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Photos section + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.PhotoCamera, + contentDescription = null, + tint = Color.Green, + modifier = Modifier.size(32.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(id = R.string.media_security_photos_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(id = R.string.media_security_photos_rating), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = Color.Green + ) + } + } + Text( + text = stringResource(id = R.string.media_security_photos_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + HorizontalDivider() + + // Videos section + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Videocam, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(id = R.string.media_security_videos_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(id = R.string.media_security_videos_rating), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + Text( + text = stringResource(id = R.string.media_security_videos_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(id = R.string.media_security_dialog_button)) + } + } + ) +} + +@Preview(showBackground = true) +@Composable +private fun SettingsContentPreview() { + val mockNavController = object : NavController { + override fun navigate(key: NavKey, builder: (NavOptions.() -> Unit)?) {} + override fun navigate(key: NavKey) {} + override fun navigateUp(): Boolean = false + } + + MaterialTheme { + SettingsContent( + navController = mockNavController, + paddingValues = PaddingValues(0.dp) + ) + } +} diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsViewModel.kt index 196d438..15bff54 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsViewModel.kt @@ -114,6 +114,14 @@ class SettingsViewModel( _uiState.update { it.copy(showSecurityResetDialog = false) } } + fun showMediaSecurityDialog() { + _uiState.update { it.copy(showMediaSecurityDialog = true) } + } + + fun dismissMediaSecurityDialog() { + _uiState.update { it.copy(showMediaSecurityDialog = false) } + } + fun performSecurityReset() { viewModelScope.launch { securityResetUseCase.reset() @@ -210,6 +218,7 @@ data class SettingsUiState( val showPoisonPillPinCreationDialog: Boolean = false, val showDecoyPhotoExplanationDialog: Boolean = false, val showRemovePoisonPillDialog: Boolean = false, + val showMediaSecurityDialog: Boolean = false, val securityResetComplete: Boolean = false, val poisonPillRemoved: Boolean = false, val securityLevel: SecurityLevel = SecurityLevel.SOFTWARE, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c81d7c9..7d563c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -182,6 +182,22 @@ 1 Minute 5 Minutes 10 Minutes + Media Security + Security strength by media type + Media Security + Photos + 10/10 + Photos taken and stored with SnapSafe are as safe as is technically + possible. They are never written to the disk unencrypted, even for brief moments. + + Videos + 9/10 + Videos taken with SnapSafe are incredibly secure, however, due to a + limitation with Android\'s video recording, they are first written to SnapSafe\'s private encrypted directory + while they are being recorded. Immediately after the recording ends, they are themselves encrypted and are + thereafter just as secure as photos taken with SnapSafe. + + Got it Security Reset From da78b36ea7a83acab68d77d36b665ae289d3e6a3 Mon Sep 17 00:00:00 2001 From: Wavesonics Date: Mon, 26 Jan 2026 22:39:22 -0800 Subject: [PATCH 04/47] Only capture volume button when in camera screen --- .../app/securecamera/MainActivity.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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 67e93a5..f1fad05 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt @@ -35,6 +35,7 @@ class MainActivity : ComponentActivity() { private val preferences: AppSettingsDataSource by inject() private val authorizationRepository: AuthorizationRepository by inject() lateinit var navController: NavController + private lateinit var navBackStack: androidx.navigation3.runtime.NavBackStack override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -53,6 +54,7 @@ class MainActivity : ComponentActivity() { val backStack = rememberNavBackStack(startKey) val controller = remember(backStack) { Nav3CompatController(backStack) } navController = controller + navBackStack = backStack App(capturePhoto, backStack, navController) } @@ -84,13 +86,19 @@ class MainActivity : ComponentActivity() { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { return when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> { - val curValue = capturePhoto.value - capturePhoto.value = if (curValue != null) { - !curValue - } else { + // Only handle volume button as shutter when on Camera screen + if (::navBackStack.isInitialized && navBackStack.lastOrNull() == Camera) { + val curValue = capturePhoto.value + capturePhoto.value = if (curValue != null) { + !curValue + } else { + true + } true + } else { + // Let the system handle volume buttons on other screens + super.onKeyDown(keyCode, event) } - true } else -> super.onKeyDown(keyCode, event) From acea5ebecfa1092643f837d7cb644ed859fd3073 Mon Sep 17 00:00:00 2001 From: Wavesonics Date: Mon, 26 Jan 2026 22:50:16 -0800 Subject: [PATCH 05/47] Remove import from back stack --- .../app/securecamera/import/ImportPhotosContent.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 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 4721368..b86e780 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,10 +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.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.navigation.* import com.darkrockstudios.app.securecamera.ui.NotificationPermissionRationale import org.koin.androidx.compose.koinViewModel import timber.log.Timber @@ -158,7 +155,8 @@ fun ImportPhotosContent( Button( modifier = Modifier.padding(16.dp), onClick = { - navController.navigateFromBase(Camera, Gallery) + // Pop ImportPhotos from backstack, then navigate to Gallery + navController.popAndNavigate(popN = 1, targetKey = Gallery) } ) { Text(stringResource(id = R.string.import_photos_done_button)) From 53e4df408f0e80b481177562b6e10146485097b9 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 27 Jan 2026 00:31:06 -0800 Subject: [PATCH 06/47] Camera UI now rotates --- .../camera/BottomCameraControls.kt | 80 +++++++++++++------ .../app/securecamera/camera/CameraContent.kt | 58 ++++++++++++++ .../app/securecamera/camera/CameraControls.kt | 15 +++- .../app/securecamera/camera/LevelIndicator.kt | 60 +++++++++----- .../camera/TopCameraControlsBar.kt | 19 +++-- .../app/securecamera/camera/ZoomMeter.kt | 5 ++ 6 files changed, 182 insertions(+), 55 deletions(-) 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 ec20ff8..85d3d29 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 @@ -10,7 +10,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription @@ -31,6 +33,7 @@ fun BottomCameraControls( onModeChange: (CaptureMode) -> Unit, isLoading: Boolean, navController: NavController, + iconRotation: Float = 0f, ) { val context = LocalContext.current @@ -40,36 +43,26 @@ fun BottomCameraControls( .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - // Mode toggle chips + // Mode toggle buttons Row( modifier = Modifier.padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - FilterChip( + ModeToggleButton( selected = captureMode == CaptureMode.PHOTO, onClick = { onModeChange(CaptureMode.PHOTO) }, enabled = !isRecording && !isLoading, - label = { Text(stringResource(R.string.camera_mode_photo)) }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Camera, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } + icon = Icons.Filled.Camera, + contentDescription = stringResource(R.string.camera_mode_photo), + iconRotation = iconRotation, ) - Spacer(modifier = Modifier.width(8.dp)) - FilterChip( + ModeToggleButton( selected = captureMode == CaptureMode.VIDEO, onClick = { onModeChange(CaptureMode.VIDEO) }, enabled = !isRecording && !isLoading, - label = { Text(stringResource(R.string.camera_mode_video)) }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Videocam, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } + icon = Icons.Filled.Videocam, + contentDescription = stringResource(R.string.camera_mode_video), + iconRotation = iconRotation, ) } @@ -85,7 +78,9 @@ fun BottomCameraControls( Icon( imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.camera_settings_button), - modifier = Modifier.size(32.dp), + modifier = Modifier + .size(32.dp) + .rotate(iconRotation), ) } @@ -110,7 +105,9 @@ fun BottomCameraControls( imageVector = Icons.Filled.Camera, contentDescription = stringResource(id = R.string.camera_capture_content_description), tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(32.dp), + modifier = Modifier + .size(32.dp) + .rotate(iconRotation), ) } } @@ -146,7 +143,9 @@ fun BottomCameraControls( imageVector = if (isRecording) Icons.Filled.Stop else Icons.Filled.FiberManualRecord, contentDescription = null, tint = Color.White, - modifier = Modifier.size(32.dp), + modifier = Modifier + .size(32.dp) + .rotate(iconRotation), ) } } @@ -161,9 +160,42 @@ fun BottomCameraControls( Icon( imageVector = Icons.Filled.PhotoLibrary, contentDescription = stringResource(id = R.string.camera_gallery_content_description), - modifier = Modifier.size(32.dp), + modifier = Modifier + .size(32.dp) + .rotate(iconRotation), ) } } } +} + +@Composable +private fun ModeToggleButton( + selected: Boolean, + onClick: () -> Unit, + enabled: Boolean, + icon: ImageVector, + contentDescription: String, + iconRotation: Float, +) { + FilledIconToggleButton( + checked = selected, + onCheckedChange = { onClick() }, + enabled = enabled, + modifier = Modifier.size(48.dp), + colors = IconButtonDefaults.filledIconToggleButtonColors( + containerColor = Color.Transparent, + contentColor = Color.White.copy(alpha = 0.6f), + checkedContainerColor = MaterialTheme.colorScheme.primaryContainer, + checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier + .size(24.dp) + .rotate(iconRotation), + ) + } } \ No newline at end of file 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 7a21ba0..fea3b54 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 @@ -3,12 +3,16 @@ package com.darkrockstudios.app.securecamera.camera import android.Manifest import android.annotation.SuppressLint import android.content.pm.ActivityInfo +import android.view.OrientationEventListener import androidx.activity.compose.LocalActivity +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import com.darkrockstudios.app.securecamera.KeepScreenOnEffect import com.darkrockstudios.app.securecamera.navigation.NavController import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -32,6 +36,57 @@ private fun LockScreenOrientationPortrait() { } } +/** + * Tracks device orientation using the accelerometer and returns the rotation angle + * that UI elements should be rotated to appear upright. + * + * Returns an animated float representing degrees: 0f, 90f, 180f, or 270f + * The animation smoothly transitions between orientations. + */ +@Composable +fun rememberDeviceRotation(): Float { + val context = LocalContext.current + var targetRotation by remember { mutableFloatStateOf(0f) } + + DisposableEffect(context) { + val orientationListener = object : OrientationEventListener(context) { + override fun onOrientationChanged(orientation: Int) { + if (orientation == ORIENTATION_UNKNOWN) return + + // Map device orientation to UI rotation + // When device is rotated clockwise, UI should rotate counter-clockwise to stay upright + val newRotation = when (orientation) { + in 45 until 135 -> 270f // Device rotated to landscape (left) + in 135 until 225 -> 180f // Device upside down + in 225 until 315 -> 90f // Device rotated to landscape (right) + else -> 0f // Portrait + } + + if (newRotation != targetRotation) { + targetRotation = newRotation + } + } + } + + if (orientationListener.canDetectOrientation()) { + orientationListener.enable() + } + + onDispose { + orientationListener.disable() + } + } + + // Animate the rotation smoothly + val animatedRotation by animateFloatAsState( + targetValue = targetRotation, + animationSpec = tween(durationMillis = 300), + label = "device_rotation" + ) + + return animatedRotation +} + @OptIn(ExperimentalPermissionsApi::class) @Composable internal fun CameraContent( @@ -70,6 +125,8 @@ internal fun CameraContent( ) } + val deviceRotation = rememberDeviceRotation() + Box( modifier = modifier .fillMaxSize() @@ -87,6 +144,7 @@ internal fun CameraContent( capturePhoto = capturePhoto, navController = navController, paddingValues = paddingValues, + iconRotation = deviceRotation, ) } else { NoCameraPermission(navController, permissionsState) 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 f0ca01a..778e437 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 @@ -18,6 +18,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -47,6 +48,7 @@ fun CameraControls( capturePhoto: MutableState, navController: NavController, paddingValues: PaddingValues, + iconRotation: Float = 0f, ) { val scope = rememberCoroutineScope() var isFlashOn by rememberSaveable(cameraController.flashMode) { mutableStateOf(cameraController.flashMode == ImageCapture.FLASH_MODE_ON) } @@ -139,13 +141,15 @@ fun CameraControls( cameraController, modifier = Modifier .align(Alignment.TopCenter) - .padding(top = paddingValues.calculateTopPadding().plus(64.dp)) + .padding(top = paddingValues.calculateTopPadding().plus(64.dp)), + textRotation = iconRotation, ) LevelIndicator( modifier = Modifier .align(Alignment.Center) - .padding(top = paddingValues.calculateTopPadding().plus(16.dp)) + .padding(top = paddingValues.calculateTopPadding().plus(16.dp)), + deviceRotation = iconRotation, ) if (isRecording) { @@ -171,6 +175,7 @@ fun CameraControls( Icon( imageVector = Icons.Filled.MoreVert, contentDescription = stringResource(id = R.string.camera_more_options_content_description), + modifier = Modifier.rotate(iconRotation), ) } } @@ -197,7 +202,8 @@ fun CameraControls( }, onLensToggle = { cameraController.toggleLens() }, onClose = { isTopControlsVisible = false }, - paddingValues = paddingValues + paddingValues = paddingValues, + iconRotation = iconRotation, ) BottomCameraControls( @@ -210,7 +216,8 @@ fun CameraControls( navController = navController, onCapture = { doCapturePhoto() }, onToggleRecording = { doToggleRecording() }, - onModeChange = { mode -> cameraController.switchCaptureMode(mode) } + onModeChange = { mode -> cameraController.switchCaptureMode(mode) }, + iconRotation = iconRotation, ) } } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/LevelIndicator.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/LevelIndicator.kt index dfdda21..2cccb4e 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/LevelIndicator.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/LevelIndicator.kt @@ -1,7 +1,6 @@ package com.darkrockstudios.app.securecamera.camera import android.content.Context -import android.content.res.Configuration import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener @@ -13,6 +12,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap @@ -24,8 +24,17 @@ import kotlin.math.tan @Composable fun LevelIndicator( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + deviceRotation: Float = 0f, ) { + // Snap to discrete rotation values (animated values may not be exactly 0/90/180/270) + val snappedRotation = when { + (deviceRotation - 90f).absoluteValue < 45f -> 90f + (deviceRotation - 180f).absoluteValue < 45f -> 180f + (deviceRotation - 270f).absoluteValue < 45f -> 270f + else -> 0f + } + val isLandscapeDevice = snappedRotation == 90f || snappedRotation == 270f val maxAngle = 15f val lineWidth = 128.dp @@ -56,7 +65,6 @@ fun LevelIndicator( } } - // Register and unregister the sensor listener DisposableEffect(Unit) { sensorManager.registerListener( sensorEventListener, @@ -69,16 +77,12 @@ fun LevelIndicator( } } - val isLandscape = context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val orientationAdjustedAngle = when { - // Landscape left (rotation 90) - isLandscape && deviceAngle > 0 -> deviceAngle - 90f - // Landscape right (rotation 270) - isLandscape && deviceAngle < 0 -> deviceAngle + 90f - // Portrait upside down (rotation 180) - !isLandscape && deviceAngle.absoluteValue > 90f -> deviceAngle + 180f - // Normal portrait (rotation 0) - else -> deviceAngle + // Use the snapped device rotation to determine angle adjustment + val orientationAdjustedAngle = when (snappedRotation) { + 270f -> deviceAngle - 90f // Landscape left + 90f -> deviceAngle + 90f // Landscape right + 180f -> deviceAngle + 180f // Portrait upside down + else -> deviceAngle // Normal portrait } val rawAdjustedAngle = orientationAdjustedAngle - 90f @@ -91,12 +95,23 @@ fun LevelIndicator( if (adjustedAngle.absoluteValue < maxAngle) { Box( modifier = modifier - .fillMaxWidth() - .height(100.dp) - .padding(top = 30.dp) + .then( + if (isLandscapeDevice) { + Modifier + .fillMaxHeight() + .width(100.dp) + .padding(end = 64.dp) + .rotate(deviceRotation) + } else { + Modifier + .fillMaxWidth() + .height(100.dp) + .padding(top = 64.dp) + } + ) ) { Canvas( - modifier = Modifier.Companion.fillMaxSize() + modifier = Modifier.fillMaxSize() ) { val canvasWidth = size.width val canvasHeight = size.height @@ -111,7 +126,7 @@ fun LevelIndicator( start = Offset(startX, centerY), end = Offset(endX, centerY), strokeWidth = 2.dp.toPx(), - cap = StrokeCap.Companion.Round + cap = StrokeCap.Round ) val color = when { @@ -132,16 +147,17 @@ fun LevelIndicator( start = Offset(startX, centerY + yOffset), end = Offset(endX, centerY - yOffset), strokeWidth = 2.dp.toPx(), - cap = StrokeCap.Companion.Round + cap = StrokeCap.Round ) } Text( text = "${adjustedAngle.toInt()}°", - color = Color.Companion.White, + color = Color.White, style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.Companion - .align(Alignment.Companion.Center) + modifier = Modifier + .align(Alignment.Center) + .padding(top = 16.dp) ) } } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/TopCameraControlsBar.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/TopCameraControlsBar.kt index cdbc44a..7e03d82 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/TopCameraControlsBar.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/TopCameraControlsBar.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag @@ -32,7 +33,8 @@ fun TopCameraControlsBar( onFaceTrackingToggle: (Boolean) -> Unit, onLensToggle: () -> Unit, onClose: () -> Unit, - paddingValues: PaddingValues? = null + paddingValues: PaddingValues? = null, + iconRotation: Float = 0f, ) { AnimatedVisibility( visible = isVisible, @@ -79,6 +81,7 @@ fun TopCameraControlsBar( imageVector = Icons.Filled.Cameraswitch, contentDescription = stringResource(id = R.string.camera_toggle_content_description), tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.rotate(iconRotation), ) } @@ -94,6 +97,7 @@ fun TopCameraControlsBar( imageVector = Icons.Filled.Close, contentDescription = stringResource(id = R.string.camera_close_controls_content_description), tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.rotate(iconRotation), ) } } @@ -105,7 +109,8 @@ fun TopCameraControlsBar( label = R.string.camera_flash_text, checked = isFlashOn, onCheckedChange = onFlashToggle, - testTage = "flash-switch" + testTage = "flash-switch", + iconRotation = iconRotation, ) Spacer(Modifier.height(16.dp)) @@ -115,7 +120,8 @@ fun TopCameraControlsBar( label = R.string.camera_face_tracking, checked = isFaceTrackingWorker, onCheckedChange = onFaceTrackingToggle, - testTage = "face-switch" + testTage = "face-switch", + iconRotation = iconRotation, ) } } @@ -128,7 +134,8 @@ private fun CameraControlSwitch( @StringRes label: Int, checked: Boolean, testTage: String, - onCheckedChange: (Boolean) -> Unit + onCheckedChange: (Boolean) -> Unit, + iconRotation: Float = 0f, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -138,7 +145,9 @@ private fun CameraControlSwitch( imageVector = icon, contentDescription = null, tint = Color.White, - modifier = Modifier.size(24.dp) + modifier = Modifier + .size(24.dp) + .rotate(iconRotation), ) Spacer(modifier = Modifier.width(8.dp)) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/ZoomMeter.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/ZoomMeter.kt index ae5c7d1..0a2cbb2 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/ZoomMeter.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/ZoomMeter.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap @@ -21,6 +22,7 @@ import kotlinx.coroutines.delay fun ZoomMeter( cameraState: CameraState, modifier: Modifier = Modifier.Companion, + textRotation: Float = 0f, ) { val zoom = cameraState.getZoomState() ?: return val zoomState by zoom.observeAsState() @@ -133,6 +135,7 @@ fun ZoomMeter( modifier = Modifier .align(Alignment.BottomStart) .padding(start = 8.dp) + .rotate(textRotation) ) // Max zoom text @@ -143,6 +146,7 @@ fun ZoomMeter( modifier = Modifier .align(Alignment.BottomEnd) .padding(end = 8.dp) + .rotate(textRotation) ) // Current zoom text @@ -153,6 +157,7 @@ fun ZoomMeter( modifier = Modifier .align(Alignment.TopCenter) .padding(top = 4.dp) + .rotate(textRotation) ) } } From a077fd74192b6c6183fe05e2d4ab642aafd042dc Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 27 Jan 2026 00:41:21 -0800 Subject: [PATCH 07/47] New camera controls layout --- .../app/securecamera/camera/CameraControls.kt | 35 ++-- .../camera/TopCameraControlsBar.kt | 154 ++++++++---------- 2 files changed, 90 insertions(+), 99 deletions(-) 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 778e437..fb99abf 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 @@ -190,21 +190,26 @@ fun CameraControls( ) } - TopCameraControlsBar( - isFlashOn = isFlashOn, - isFaceTrackingWorker = cameraController.faceTracking == true, - isVisible = isTopControlsVisible && !isRecording, - onFlashToggle = { - isFlashOn = !isFlashOn - }, - onFaceTrackingToggle = { enabled -> - cameraController.setEnableFaceTracking(enabled) - }, - onLensToggle = { cameraController.toggleLens() }, - onClose = { isTopControlsVisible = false }, - paddingValues = paddingValues, - iconRotation = iconRotation, - ) + Box( + modifier = Modifier + .align(Alignment.TopEnd) + ) { + TopCameraControlsBar( + isFlashOn = isFlashOn, + isFaceTrackingWorker = cameraController.faceTracking == true, + isVisible = isTopControlsVisible && !isRecording, + onFlashToggle = { + isFlashOn = !isFlashOn + }, + onFaceTrackingToggle = { enabled -> + cameraController.setEnableFaceTracking(enabled) + }, + onLensToggle = { cameraController.toggleLens() }, + onClose = { isTopControlsVisible = false }, + paddingValues = paddingValues, + iconRotation = iconRotation, + ) + } BottomCameraControls( modifier = Modifier diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/TopCameraControlsBar.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/TopCameraControlsBar.kt index 7e03d82..73b3041 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/TopCameraControlsBar.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/TopCameraControlsBar.kt @@ -1,11 +1,8 @@ package com.darkrockstudios.app.securecamera.camera -import androidx.annotation.StringRes import androidx.compose.animation.* import androidx.compose.animation.core.tween -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cameraswitch @@ -49,78 +46,52 @@ fun TopCameraControlsBar( ) { Surface( modifier = Modifier - .fillMaxWidth() .padding( - start = 16.dp, end = 16.dp, top = paddingValues?.calculateTopPadding()?.plus(16.dp) ?: 16.dp, - bottom = 16.dp ), color = Color.Black.copy(alpha = 0.6f), shape = RoundedCornerShape(16.dp) ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - FilledTonalButton( - onClick = onLensToggle, - modifier = Modifier - .background(MaterialTheme.colorScheme.primary, CircleShape), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Icon( - imageVector = Icons.Filled.Cameraswitch, - contentDescription = stringResource(id = R.string.camera_toggle_content_description), - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.rotate(iconRotation), - ) - } + CompactControlButton( + onClick = onClose, + icon = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.camera_close_controls_content_description), + iconRotation = iconRotation, + ) - FilledTonalButton( - onClick = onClose, - modifier = Modifier - .background(MaterialTheme.colorScheme.primary, CircleShape), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.camera_close_controls_content_description), - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.rotate(iconRotation), - ) - } - } + CompactControlButton( + onClick = onLensToggle, + icon = Icons.Filled.Cameraswitch, + contentDescription = stringResource(id = R.string.camera_toggle_content_description), + iconRotation = iconRotation, + ) - Spacer(Modifier.height(16.dp)) + HorizontalDivider( + modifier = Modifier.width(40.dp), + color = Color.White.copy(alpha = 0.3f), + ) - CameraControlSwitch( - icon = if (isFlashOn) Flashlight else FlashlightOff, - label = R.string.camera_flash_text, + CompactToggleButton( checked = isFlashOn, onCheckedChange = onFlashToggle, - testTage = "flash-switch", + icon = if (isFlashOn) Flashlight else FlashlightOff, + contentDescription = stringResource(id = R.string.camera_flash_text), + testTag = "flash-switch", iconRotation = iconRotation, ) - Spacer(Modifier.height(16.dp)) - - CameraControlSwitch( - icon = if (isFaceTrackingWorker) FaceTrackingOn else FaceTrackingOff, - label = R.string.camera_face_tracking, + CompactToggleButton( checked = isFaceTrackingWorker, onCheckedChange = onFaceTrackingToggle, - testTage = "face-switch", + icon = if (isFaceTrackingWorker) FaceTrackingOn else FaceTrackingOff, + contentDescription = stringResource(id = R.string.camera_face_tracking), + testTag = "face-switch", iconRotation = iconRotation, ) } @@ -129,44 +100,59 @@ fun TopCameraControlsBar( } @Composable -private fun CameraControlSwitch( +private fun CompactControlButton( + onClick: () -> Unit, icon: ImageVector, - @StringRes label: Int, - checked: Boolean, - testTage: String, - onCheckedChange: (Boolean) -> Unit, - iconRotation: Float = 0f, + contentDescription: String, + iconRotation: Float, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 8.dp) + FilledTonalButton( + onClick = onClick, + modifier = Modifier.size(48.dp), + contentPadding = PaddingValues(0.dp), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primary + ) ) { Icon( imageVector = icon, - contentDescription = null, - tint = Color.White, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier .size(24.dp) .rotate(iconRotation), ) - Spacer(modifier = Modifier.width(8.dp)) - - Switch( - modifier = Modifier.testTag(testTage), - checked = checked, - onCheckedChange = onCheckedChange, - colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer - ) - ) - - Spacer(modifier = Modifier.width(8.dp)) + } +} - Text( - text = stringResource(id = label), - color = Color.White, - style = MaterialTheme.typography.bodyMedium +@Composable +private fun CompactToggleButton( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + icon: ImageVector, + contentDescription: String, + testTag: String, + iconRotation: Float, +) { + FilledIconToggleButton( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier + .size(48.dp) + .testTag(testTag), + colors = IconButtonDefaults.filledIconToggleButtonColors( + containerColor = Color.White.copy(alpha = 0.2f), + contentColor = Color.White, + checkedContainerColor = MaterialTheme.colorScheme.primaryContainer, + checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier + .size(24.dp) + .rotate(iconRotation), ) } } From f5853f2a30c4660a4ce8d860c2e1a7bcdaacb1df Mon Sep 17 00:00:00 2001 From: Wavesonics Date: Tue, 27 Jan 2026 22:58:10 -0800 Subject: [PATCH 08/47] Improved SECV format removed the need for the index table --- .../streaming/ChunkedStreamingDecryptor.kt | 63 ++- .../streaming/ChunkedStreamingEncryptor.kt | 64 ++- .../security/streaming/SecvFileFormat.kt | 92 ++-- .../ChunkedStreamingEncryptionTest.kt | 396 ++++++++++++++++++ .../security/streaming/SecvFileFormatTest.kt | 215 ++++++++++ .../streaming/SecvFormatValidationTest.kt | 338 +++++++++++++++ docs/Video Encryption.md | 87 ++-- 7 files changed, 1103 insertions(+), 152 deletions(-) create mode 100644 app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingEncryptionTest.kt create mode 100644 app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFileFormatTest.kt create mode 100644 app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFormatValidationTest.kt diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingDecryptor.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingDecryptor.kt index 6775b70..4214989 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingDecryptor.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingDecryptor.kt @@ -13,8 +13,8 @@ import javax.crypto.spec.SecretKeySpec /** * Implementation of StreamingDecryptor that decrypts SECV files chunk by chunk. - * Supports random access for video seeking by using the chunk index table. - * Reads the trailer format with trailer and index at the end of the file. + * Supports random access for video seeking by calculating chunk offsets arithmetically. + * Reads the header format with metadata at the start of the file. * * Thread-safe via mutex for all read operations. */ @@ -27,48 +27,31 @@ class ChunkedStreamingDecryptor( private val randomAccessFile: RandomAccessFile private var isClosed = false - private val trailer: SecvFileFormat.SecvTrailer - private val chunkIndex: List + private val header: SecvFileFormat.SecvHeader // Cache for the most recently decrypted chunk to avoid re-decryption on sequential reads private var cachedChunkIndex: Long = -1 private var cachedChunkData: ByteArray? = null override val totalSize: Long - get() = trailer.originalSize + get() = header.originalSize override val chunkSize: Int - get() = trailer.chunkSize + get() = header.chunkSize init { require(encryptedFile.exists()) { "Encrypted file does not exist: ${encryptedFile.absolutePath}" } randomAccessFile = RandomAccessFile(encryptedFile, "r") ?: error("Failed to open file for reading") - val fileLength = randomAccessFile.length() - // Read trailer from end of file - val trailerPosition = SecvFileFormat.calculateTrailerPosition(fileLength) - randomAccessFile.seek(trailerPosition) - val trailerBytes = ByteArray(SecvFileFormat.TRAILER_SIZE) - randomAccessFile.readFully(trailerBytes) - trailer = SecvFileFormat.SecvTrailer.fromByteArray(trailerBytes) + // Read header from start of file + randomAccessFile.seek(0) + val headerBytes = ByteArray(SecvFileFormat.HEADER_SIZE) + randomAccessFile.readFully(headerBytes) + header = SecvFileFormat.SecvHeader.fromByteArray(headerBytes) - require(trailer.version == SecvFileFormat.VERSION) { - "Unsupported SECV version: ${trailer.version}" - } - - // Read chunk index table (just before trailer) - val indexPosition = SecvFileFormat.calculateIndexTablePosition(fileLength, trailer.totalChunks) - randomAccessFile.seek(indexPosition) - val indexTableSize = trailer.totalChunks * SecvFileFormat.CHUNK_INDEX_ENTRY_SIZE - val indexBytes = ByteArray(indexTableSize.toInt()) - randomAccessFile.readFully(indexBytes) - - chunkIndex = (0 until trailer.totalChunks).map { i -> - SecvFileFormat.ChunkIndexEntry.fromByteArray( - indexBytes, - (i * SecvFileFormat.CHUNK_INDEX_ENTRY_SIZE).toInt() - ) + require(header.version == SecvFileFormat.VERSION) { + "Unsupported SECV version: ${header.version}" } } @@ -78,6 +61,11 @@ class ChunkedStreamingDecryptor( require(length >= 0) { "Length must be non-negative" } require(offset + length <= buffer.size) { "Offset + length exceeds buffer size" } + // Reading 0 bytes always succeeds and returns 0 + if (length == 0) { + return 0 + } + if (position >= totalSize) { return -1 // EOF } @@ -138,13 +126,22 @@ class ChunkedStreamingDecryptor( private suspend fun decryptChunk(chunkIdx: Long): ByteArray = withContext(Dispatchers.IO) { val raf = randomAccessFile - require(chunkIdx < chunkIndex.size) { "Chunk index out of bounds: $chunkIdx" } + require(chunkIdx < header.totalChunks) { "Chunk index out of bounds: $chunkIdx" } + + // Calculate chunk offset arithmetically + val chunkOffset = SecvFileFormat.calculateChunkOffset(chunkIdx, header.chunkSize) - val entry = chunkIndex[chunkIdx.toInt()] + // Determine encrypted size based on whether this is the final chunk + val isFinalChunk = (chunkIdx == header.totalChunks - 1) + val encryptedSize = if (isFinalChunk) { + SecvFileFormat.calculateEncryptedChunkSize(header.finalChunkPlaintextSize) + } else { + SecvFileFormat.calculateFullEncryptedChunkSize(header.chunkSize) + } // Read the encrypted chunk data (IV + ciphertext with auth tag) - val encryptedData = ByteArray(entry.encryptedSize) - raf.seek(entry.offset) + val encryptedData = ByteArray(encryptedSize) + raf.seek(chunkOffset) raf.readFully(encryptedData) val iv = encryptedData.copyOfRange(0, SecvFileFormat.IV_SIZE) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingEncryptor.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingEncryptor.kt index a5224b1..ea1d3f9 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingEncryptor.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingEncryptor.kt @@ -16,10 +16,10 @@ import javax.crypto.spec.SecretKeySpec /** * Implementation of StreamingEncryptor that encrypts data in chunks using AES-GCM. * - * This encryptor writes data in the SECV trailer format: - * 1. Encrypted chunks are written sequentially as data arrives - * 2. On close, the index table and trailer are appended to the end - * 3. No file rewriting needed, preventing memory spikes on large videos + * This encryptor writes data in the SECV header format: + * 1. Writes 64-byte placeholder header at start + * 2. Encrypted chunks are written sequentially as data arrives + * 3. On close, seeks to position 0 and writes final header with metadata * * Thread-safe via mutex for all write operations. */ @@ -36,7 +36,8 @@ class ChunkedStreamingEncryptor( private var currentBuffer = ByteArray(chunkSize) private var bufferPosition = 0 private var totalBytesWritten = 0L - private val chunkIndexEntries = mutableListOf() + private var totalChunks = 0L + private var finalChunkPlaintextSize = 0 private var isClosed = false private var isFlushed = false @@ -48,9 +49,13 @@ class ChunkedStreamingEncryptor( randomAccessFile = RandomAccessFile(outputFile, "rw") - // Chunks are written starting at offset 0 - // Index table and trailer will be appended at the end - currentChunkDataOffset = 0L + // Write placeholder header (64 zero bytes) at start + // Will be filled in with actual metadata on close() + val placeholderHeader = ByteArray(SecvFileFormat.HEADER_SIZE) + randomAccessFile?.write(placeholderHeader) + + // Chunks are written starting after the header + currentChunkDataOffset = SecvFileFormat.HEADER_SIZE.toLong() } override suspend fun write(data: ByteArray, offset: Int, length: Int) { @@ -101,7 +106,7 @@ class ChunkedStreamingEncryptor( } /** - * Encrypts a chunk of data and writes it to the temporary storage. + * Encrypts a chunk of data and writes it to the file. * Must be called while holding the mutex. */ private suspend fun writeChunk(data: ByteArray, length: Int) = withContext(Dispatchers.IO) { @@ -122,20 +127,16 @@ class ChunkedStreamingEncryptor( val plaintext = if (length == data.size) data else data.copyOf(length) val ciphertext = cipher.doFinal(plaintext) - // Record the chunk index entry - val encryptedSize = SecvFileFormat.IV_SIZE + ciphertext.size - chunkIndexEntries.add( - SecvFileFormat.ChunkIndexEntry( - offset = currentChunkDataOffset, - encryptedSize = encryptedSize - ) - ) + // Track chunk count and final chunk plaintext size + totalChunks++ + finalChunkPlaintextSize = length // Write IV + ciphertext (which includes auth tag) raf.seek(currentChunkDataOffset) raf.write(iv) raf.write(ciphertext) + val encryptedSize = SecvFileFormat.IV_SIZE + ciphertext.size currentChunkDataOffset += encryptedSize } finally { // Immediately zero key bytes after use @@ -164,17 +165,15 @@ class ChunkedStreamingEncryptor( val plaintext = currentBuffer.copyOf(bufferPosition) val ciphertext = cipher.doFinal(plaintext) - val encryptedSize = SecvFileFormat.IV_SIZE + ciphertext.size - chunkIndexEntries.add( - SecvFileFormat.ChunkIndexEntry( - offset = currentChunkDataOffset, - encryptedSize = encryptedSize - ) - ) + // Track chunk count and final chunk plaintext size + totalChunks++ + finalChunkPlaintextSize = bufferPosition raf.seek(currentChunkDataOffset) raf.write(iv) raf.write(ciphertext) + + val encryptedSize = SecvFileFormat.IV_SIZE + ciphertext.size currentChunkDataOffset += encryptedSize bufferPosition = 0 } finally { @@ -183,19 +182,16 @@ class ChunkedStreamingEncryptor( } } - // Append index table at current position - for (entry in chunkIndexEntries) { - raf.write(entry.toByteArray()) - } - - // Append trailer at end (now we know totalChunks and originalSize) - val trailer = SecvFileFormat.SecvTrailer( + // Seek to beginning and write header with final metadata + raf.seek(0) + val header = SecvFileFormat.SecvHeader( version = SecvFileFormat.VERSION, chunkSize = chunkSize, - totalChunks = chunkIndexEntries.size.toLong(), - originalSize = totalBytesWritten + totalChunks = totalChunks, + originalSize = totalBytesWritten, + finalChunkPlaintextSize = finalChunkPlaintextSize ) - raf.write(trailer.toByteArray()) + raf.write(header.toByteArray()) randomAccessFile?.close() randomAccessFile = null diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFileFormat.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFileFormat.kt index 508c05e..b52aa26 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFileFormat.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFileFormat.kt @@ -6,55 +6,52 @@ import java.nio.ByteOrder /** * Constants and utilities for the SECV (Secure Encrypted Camera Video) file format. * - * File Format: - * [Encrypted Chunks] - * - Per chunk: [12-byte IV][ciphertext][16-byte auth tag] - * - * [Chunk Index Table: 12 bytes per chunk] - * - Chunk offset: uint64 (8 bytes) - * - Encrypted size: uint32 (4 bytes) - * - * [Trailer: 64 bytes] - Located at end of file + * File Format (Version 1): + * [Header: 64 bytes] - Located at start of file * - Magic: "SECV" (4 bytes) * - Version: uint16 (2 bytes) * - Chunk size: uint32 (4 bytes) * - Total chunks: uint64 (8 bytes) * - Original size: uint64 (8 bytes) - * - Reserved: padding to 64 bytes (38 bytes) + * - Final chunk plaintext size: uint32 (4 bytes) + * - Reserved: padding to 64 bytes (34 bytes) * - * The trailer format (chunks first, metadata at end) eliminates the need - * to rewrite the entire file when encryption completes, preventing memory - * spikes from loading large videos into RAM. + * [Encrypted Chunks] + * - Per chunk: [12-byte IV][ciphertext][16-byte auth tag] + * + * Design Rationale: + * - Chunk offsets calculated arithmetically: offset = 64 + (chunkIndex * (chunkSize + 28)) */ object SecvFileFormat { const val MAGIC = "SECV" const val VERSION: Short = 1 - const val TRAILER_SIZE = 64 - const val CHUNK_INDEX_ENTRY_SIZE = 12 + const val HEADER_SIZE = 64 const val IV_SIZE = 12 const val AUTH_TAG_SIZE = 16 const val DEFAULT_CHUNK_SIZE = 1_048_576 // 1MB const val FILE_EXTENSION = "secv" - // Trailer field offsets + // Header field offsets private const val OFFSET_MAGIC = 0 private const val OFFSET_VERSION = 4 private const val OFFSET_CHUNK_SIZE = 6 private const val OFFSET_TOTAL_CHUNKS = 10 private const val OFFSET_ORIGINAL_SIZE = 18 + private const val OFFSET_FINAL_CHUNK_SIZE = 26 /** - * Represents the trailer of a SECV file (metadata at end of file). + * Represents the header of a SECV file (metadata at start of file). */ - data class SecvTrailer( + data class SecvHeader( val version: Short, val chunkSize: Int, val totalChunks: Long, - val originalSize: Long + val originalSize: Long, + val finalChunkPlaintextSize: Int ) { fun toByteArray(): ByteArray { - val buffer = ByteBuffer.allocate(TRAILER_SIZE) + val buffer = ByteBuffer.allocate(HEADER_SIZE) buffer.order(ByteOrder.LITTLE_ENDIAN) // Magic @@ -67,14 +64,16 @@ object SecvFileFormat { buffer.putLong(totalChunks) // Original size buffer.putLong(originalSize) + // Final chunk plaintext size + buffer.putInt(finalChunkPlaintextSize) // Reserved (remaining bytes are zero by default) return buffer.array() } companion object { - fun fromByteArray(bytes: ByteArray): SecvTrailer { - require(bytes.size >= TRAILER_SIZE) { "Trailer too small" } + fun fromByteArray(bytes: ByteArray): SecvHeader { + require(bytes.size >= HEADER_SIZE) { "Header too small" } val buffer = ByteBuffer.wrap(bytes) buffer.order(ByteOrder.LITTLE_ENDIAN) @@ -88,39 +87,14 @@ object SecvFileFormat { val chunkSize = buffer.int val totalChunks = buffer.long val originalSize = buffer.long + val finalChunkPlaintextSize = buffer.int - return SecvTrailer( + return SecvHeader( version = version, chunkSize = chunkSize, totalChunks = totalChunks, - originalSize = originalSize - ) - } - } - } - - /** - * Represents an entry in the chunk index table. - */ - data class ChunkIndexEntry( - val offset: Long, - val encryptedSize: Int - ) { - fun toByteArray(): ByteArray { - val buffer = ByteBuffer.allocate(CHUNK_INDEX_ENTRY_SIZE) - buffer.order(ByteOrder.LITTLE_ENDIAN) - buffer.putLong(offset) - buffer.putInt(encryptedSize) - return buffer.array() - } - - companion object { - fun fromByteArray(bytes: ByteArray, offset: Int = 0): ChunkIndexEntry { - val buffer = ByteBuffer.wrap(bytes, offset, CHUNK_INDEX_ENTRY_SIZE) - buffer.order(ByteOrder.LITTLE_ENDIAN) - return ChunkIndexEntry( - offset = buffer.long, - encryptedSize = buffer.int + originalSize = originalSize, + finalChunkPlaintextSize = finalChunkPlaintextSize ) } } @@ -135,19 +109,19 @@ object SecvFileFormat { } /** - * Calculate the position of the trailer in the file (last 64 bytes). - * For trailer format, trailer is at: fileLength - TRAILER_SIZE + * Calculate the size of a full encrypted chunk. + * Full chunk size = IV (12 bytes) + chunkSize + auth tag (16 bytes) = chunkSize + 28 */ - fun calculateTrailerPosition(fileLength: Long): Long { - return fileLength - TRAILER_SIZE + fun calculateFullEncryptedChunkSize(chunkSize: Int): Int { + return chunkSize + IV_SIZE + AUTH_TAG_SIZE } /** - * Calculate the position of the index table in the file. - * For trailer format, index is at: fileLength - TRAILER_SIZE - (totalChunks * CHUNK_INDEX_ENTRY_SIZE) + * Calculate the file offset for a given chunk index. + * Offset = header (64 bytes) + (chunkIndex * full encrypted chunk size) */ - fun calculateIndexTablePosition(fileLength: Long, totalChunks: Long): Long { - return fileLength - TRAILER_SIZE - (totalChunks * CHUNK_INDEX_ENTRY_SIZE) + fun calculateChunkOffset(chunkIndex: Long, chunkSize: Int): Long { + return HEADER_SIZE + (chunkIndex * calculateFullEncryptedChunkSize(chunkSize)) } /** diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingEncryptionTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingEncryptionTest.kt new file mode 100644 index 0000000..2b5874f --- /dev/null +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingEncryptionTest.kt @@ -0,0 +1,396 @@ +package com.darkrockstudios.app.securecamera.security.streaming + +import com.darkrockstudios.app.securecamera.preferences.HashedPin +import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.File +import java.io.RandomAccessFile +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ChunkedStreamingEncryptionTest { + + private lateinit var testDir: File + private lateinit var encryptionScheme: TestEncryptionScheme + + @Before + fun setup() { + testDir = File(System.getProperty("java.io.tmpdir"), "secv-test-${System.currentTimeMillis()}") + testDir.mkdirs() + encryptionScheme = TestEncryptionScheme() + } + + @After + fun cleanup() { + testDir.deleteRecursively() + } + + @Test + fun `encrypt and decrypt should round-trip correctly`() = runTest { + // Given + val plaintext = "Hello, SECV!".repeat(100).toByteArray() + val encryptedFile = File(testDir, "test.secv") + + // When - Encrypt + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize = 1024) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.flush() + encryptor.close() + + // When - Decrypt + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + val decrypted = ByteArray(plaintext.size) + val bytesRead = decryptor.read(0, decrypted, 0, plaintext.size) + decryptor.close() + + // Then + assertEquals(plaintext.size, bytesRead) + assertContentEquals(plaintext, decrypted) + } + + @Test + fun `encrypted file should have correct header`() = runTest { + // Given + val plaintext = ByteArray(5000) { it.toByte() } + val chunkSize = 1024 + val encryptedFile = File(testDir, "test.secv") + + // When + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // Then - Verify header + RandomAccessFile(encryptedFile, "r").use { raf -> + val headerBytes = ByteArray(SecvFileFormat.HEADER_SIZE) + raf.readFully(headerBytes) + val header = SecvFileFormat.SecvHeader.fromByteArray(headerBytes) + + assertEquals(SecvFileFormat.VERSION, header.version) + assertEquals(chunkSize, header.chunkSize) + assertEquals(5L, header.totalChunks) // 1024*4 + 904 = 5 chunks + assertEquals(plaintext.size.toLong(), header.originalSize) + assertEquals(904, header.finalChunkPlaintextSize) // 5000 % 1024 = 904 + } + } + + @Test + fun `encrypted file should have correct size`() = runTest { + // Given + val plaintext = ByteArray(3000) { it.toByte() } + val chunkSize = 1024 + val encryptedFile = File(testDir, "test.secv") + + // When + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // Then + // Expected: 64 (header) + 2*(1024+28) + 952+28 = 64 + 2104 + 980 = 3148 + val expectedFullChunks = 2 + val expectedFinalChunk = 952 // 3000 % 1024 + val expectedSize = SecvFileFormat.HEADER_SIZE + + (expectedFullChunks * (chunkSize + 28)) + + (expectedFinalChunk + 28) + assertEquals(expectedSize.toLong(), encryptedFile.length()) + } + + @Test + fun `should handle exact multiple of chunk size`() = runTest { + // Given + val chunkSize = 1024 + val plaintext = ByteArray(chunkSize * 3) { it.toByte() } // Exactly 3 chunks + val encryptedFile = File(testDir, "test.secv") + + // When - Encrypt + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // Then - Decrypt + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + val decrypted = ByteArray(plaintext.size) + decryptor.read(0, decrypted, 0, plaintext.size) + decryptor.close() + + assertContentEquals(plaintext, decrypted) + + // Verify header + RandomAccessFile(encryptedFile, "r").use { raf -> + val headerBytes = ByteArray(SecvFileFormat.HEADER_SIZE) + raf.readFully(headerBytes) + val header = SecvFileFormat.SecvHeader.fromByteArray(headerBytes) + assertEquals(3L, header.totalChunks) + assertEquals(chunkSize, header.finalChunkPlaintextSize) // Final chunk is full size + } + } + + @Test + fun `should handle single chunk video`() = runTest { + // Given + val chunkSize = 1024 + val plaintext = ByteArray(512) { it.toByte() } // Less than one chunk + val encryptedFile = File(testDir, "test.secv") + + // When - Encrypt + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // Then - Decrypt + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + val decrypted = ByteArray(plaintext.size) + decryptor.read(0, decrypted, 0, plaintext.size) + decryptor.close() + + assertContentEquals(plaintext, decrypted) + + // Verify header + RandomAccessFile(encryptedFile, "r").use { raf -> + val headerBytes = ByteArray(SecvFileFormat.HEADER_SIZE) + raf.readFully(headerBytes) + val header = SecvFileFormat.SecvHeader.fromByteArray(headerBytes) + assertEquals(1L, header.totalChunks) + assertEquals(512, header.finalChunkPlaintextSize) + } + } + + @Test + fun `should handle empty file`() = runTest { + // Given + val plaintext = ByteArray(0) + val encryptedFile = File(testDir, "test.secv") + + // When - Encrypt + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize = 1024) + encryptor.write(plaintext, 0, 0) + encryptor.close() + + // Then - Decrypt + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + val decrypted = ByteArray(0) + val bytesRead = decryptor.read(0, decrypted, 0, 0) + decryptor.close() + + assertEquals(0, bytesRead) + assertEquals(0L, decryptor.totalSize) + } + + @Test + fun `should support seeking to different positions`() = runTest { + // Given + val chunkSize = 100 + val plaintext = ByteArray(500) { it.toByte() } // 5 chunks + val encryptedFile = File(testDir, "test.secv") + + // Encrypt + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // When - Decrypt from different positions + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + + // Read from start + val buffer1 = ByteArray(10) + decryptor.read(0, buffer1, 0, 10) + assertContentEquals(plaintext.copyOfRange(0, 10), buffer1) + + // Read from middle of first chunk + val buffer2 = ByteArray(10) + decryptor.read(50, buffer2, 0, 10) + assertContentEquals(plaintext.copyOfRange(50, 60), buffer2) + + // Read from start of second chunk + val buffer3 = ByteArray(10) + decryptor.read(100, buffer3, 0, 10) + assertContentEquals(plaintext.copyOfRange(100, 110), buffer3) + + // Read from near end + val buffer4 = ByteArray(10) + decryptor.read(490, buffer4, 0, 10) + assertContentEquals(plaintext.copyOfRange(490, 500), buffer4) + + // Read across chunk boundary + val buffer5 = ByteArray(20) + decryptor.read(95, buffer5, 0, 20) + assertContentEquals(plaintext.copyOfRange(95, 115), buffer5) + + decryptor.close() + } + + @Test + fun `should handle large file`() = runTest { + // Given + val chunkSize = 1024 + val plaintext = ByteArray(1_048_576) { (it % 256).toByte() } // 1MB + val encryptedFile = File(testDir, "test.secv") + + // When - Encrypt + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // Then - Decrypt + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + val decrypted = ByteArray(plaintext.size) + var totalRead = 0 + var position = 0L + + // Read in chunks to simulate streaming + while (totalRead < plaintext.size) { + val toRead = minOf(8192, plaintext.size - totalRead) + val read = decryptor.read(position, decrypted, totalRead, toRead) + if (read <= 0) break + totalRead += read + position += read + } + decryptor.close() + + assertEquals(plaintext.size, totalRead) + assertContentEquals(plaintext, decrypted) + } + + @Test + fun `should handle multiple sequential writes`() = runTest { + // Given + val part1 = "Hello ".toByteArray() + val part2 = "World".toByteArray() + val part3 = "!".toByteArray() + val encryptedFile = File(testDir, "test.secv") + + // When - Encrypt with multiple writes + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize = 1024) + encryptor.write(part1, 0, part1.size) + encryptor.write(part2, 0, part2.size) + encryptor.write(part3, 0, part3.size) + encryptor.close() + + // Then - Decrypt + val expected = "Hello World!".toByteArray() + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + val decrypted = ByteArray(expected.size) + decryptor.read(0, decrypted, 0, expected.size) + decryptor.close() + + assertContentEquals(expected, decrypted) + } + + @Test + fun `decryptor should expose correct metadata`() = runTest { + // Given + val chunkSize = 2048 + val plaintext = ByteArray(10000) { it.toByte() } + val encryptedFile = File(testDir, "test.secv") + + // When + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + + // Then + assertEquals(plaintext.size.toLong(), decryptor.totalSize) + assertEquals(chunkSize, decryptor.chunkSize) + decryptor.close() + } + + @Test + fun `should handle read beyond EOF`() = runTest { + // Given + val plaintext = ByteArray(100) { it.toByte() } + val encryptedFile = File(testDir, "test.secv") + + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize = 1024) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // When - Try to read beyond EOF + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + val buffer = ByteArray(10) + val bytesRead = decryptor.read(100, buffer, 0, 10) // Read at EOF + decryptor.close() + + // Then + assertEquals(-1, bytesRead, "Reading at EOF should return -1") + } + + @Test + fun `should handle partial read at end of file`() = runTest { + // Given + val plaintext = ByteArray(100) { it.toByte() } + val encryptedFile = File(testDir, "test.secv") + + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize = 1024) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // When - Try to read more than available + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + val buffer = ByteArray(20) + val bytesRead = decryptor.read(90, buffer, 0, 20) // Only 10 bytes available + decryptor.close() + + // Then + assertEquals(10, bytesRead, "Should read only available bytes") + assertContentEquals(plaintext.copyOfRange(90, 100), buffer.copyOfRange(0, 10)) + } + + @Test + fun `encrypted file should not contain plaintext`() = runTest { + // Given + val plaintext = "SECRET_DATA_12345".repeat(10).toByteArray() + val encryptedFile = File(testDir, "test.secv") + + // When + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize = 1024) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // Then - Read raw file and verify plaintext is not present + val rawBytes = encryptedFile.readBytes() + val plaintextString = String(plaintext) + val rawString = String(rawBytes, Charsets.ISO_8859_1) // Use binary-safe charset + + // The plaintext should not appear in the encrypted file + // (We can't do a simple contains check because of potential false positives, + // but we can verify the file is not just the plaintext) + assertTrue(rawBytes.size > plaintext.size, "Encrypted file should be larger (has header + IV + tags)") + + // Verify header exists + val headerBytes = rawBytes.copyOfRange(0, SecvFileFormat.HEADER_SIZE) + val header = SecvFileFormat.SecvHeader.fromByteArray(headerBytes) + assertNotNull(header) + } + + /** + * Simple test encryption scheme that uses a fixed key for testing. + */ + private class TestEncryptionScheme : EncryptionScheme { + private val testKey = ByteArray(32) { it.toByte() } + + override suspend fun getDerivedKey(): ByteArray = testKey.copyOf() + + // Unused methods for this test + override suspend fun encryptToFile(plain: ByteArray, targetFile: File) = error("Not used") + override suspend fun encryptToFile(plain: ByteArray, keyBytes: ByteArray, targetFile: File) = error("Not used") + override suspend fun encrypt(plain: ByteArray, keyBytes: ByteArray): ByteArray = error("Not used") + override suspend fun encryptWithKeyAlias(plain: ByteArray, keyAlias: String): ByteArray = error("Not used") + override suspend fun decryptWithKeyAlias(encrypted: ByteArray, keyAlias: String): ByteArray = error("Not used") + override suspend fun decryptFile(encryptedFile: File): ByteArray = error("Not used") + override suspend fun deriveAndCacheKey(plainPin: String, hashedPin: HashedPin) = error("Not used") + override suspend fun deriveKey(plainPin: String, hashedPin: HashedPin): ByteArray = error("Not used") + override fun evictKey() = Unit + override suspend fun createKey(plainPin: String, hashedPin: HashedPin) = error("Not used") + override suspend fun securityFailureReset() = error("Not used") + override fun activatePoisonPill(oldPin: HashedPin?) = Unit + override fun getStreamingCapability(): StreamingEncryptionScheme? = null + } +} diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFileFormatTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFileFormatTest.kt new file mode 100644 index 0000000..0c318d5 --- /dev/null +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFileFormatTest.kt @@ -0,0 +1,215 @@ +package com.darkrockstudios.app.securecamera.security.streaming + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SecvFileFormatTest { + + @Test + fun `calculateEncryptedChunkSize should add IV and auth tag`() { + // Given + val plaintextSize = 1024 + + // When + val encryptedSize = SecvFileFormat.calculateEncryptedChunkSize(plaintextSize) + + // Then + val expected = plaintextSize + SecvFileFormat.IV_SIZE + SecvFileFormat.AUTH_TAG_SIZE + assertEquals(expected, encryptedSize, "Encrypted size should be plaintext + 12 (IV) + 16 (tag)") + } + + @Test + fun `calculateFullEncryptedChunkSize should return chunk size plus 28`() { + // Given + val chunkSize = 1_048_576 // 1MB + + // When + val fullEncryptedSize = SecvFileFormat.calculateFullEncryptedChunkSize(chunkSize) + + // Then + assertEquals( + chunkSize + 28, + fullEncryptedSize, + "Full encrypted chunk size should be chunk size + 28" + ) + } + + @Test + fun `calculateChunkOffset should return correct offset for first chunk`() { + // Given + val chunkIndex = 0L + val chunkSize = 1_048_576 + + // When + val offset = SecvFileFormat.calculateChunkOffset(chunkIndex, chunkSize) + + // Then + assertEquals( + SecvFileFormat.HEADER_SIZE.toLong(), + offset, + "First chunk should start at offset 64 (after header)" + ) + } + + @Test + fun `calculateChunkOffset should return correct offset for second chunk`() { + // Given + val chunkIndex = 1L + val chunkSize = 1_048_576 + + // When + val offset = SecvFileFormat.calculateChunkOffset(chunkIndex, chunkSize) + + // Then + val expected = SecvFileFormat.HEADER_SIZE + (chunkSize + 28) + assertEquals( + expected.toLong(), + offset, + "Second chunk should start after header + first chunk" + ) + } + + @Test + fun `calculateChunkOffset should return correct offset for arbitrary chunk`() { + // Given + val chunkIndex = 100L + val chunkSize = 1_048_576 + + // When + val offset = SecvFileFormat.calculateChunkOffset(chunkIndex, chunkSize) + + // Then + val expected = SecvFileFormat.HEADER_SIZE + (chunkIndex * (chunkSize + 28)) + assertEquals(expected, offset, "Chunk 100 should have correct calculated offset") + } + + @Test + fun `calculatePlaintextOffset should return correct offset`() { + // Given + val chunkIndex = 5L + val chunkSize = 1024 + + // When + val offset = SecvFileFormat.calculatePlaintextOffset(chunkIndex, chunkSize) + + // Then + assertEquals(5120L, offset, "Plaintext offset should be chunk index * chunk size") + } + + @Test + fun `SecvHeader serialization should round-trip correctly`() { + // Given + val header = SecvFileFormat.SecvHeader( + version = 1, + chunkSize = 1_048_576, + totalChunks = 100L, + originalSize = 104_857_600L, + finalChunkPlaintextSize = 1024 + ) + + // When + val bytes = header.toByteArray() + val deserialized = SecvFileFormat.SecvHeader.fromByteArray(bytes) + + // Then + assertEquals(SecvFileFormat.HEADER_SIZE, bytes.size, "Header should be 64 bytes") + assertEquals(header.version, deserialized.version) + assertEquals(header.chunkSize, deserialized.chunkSize) + assertEquals(header.totalChunks, deserialized.totalChunks) + assertEquals(header.originalSize, deserialized.originalSize) + assertEquals(header.finalChunkPlaintextSize, deserialized.finalChunkPlaintextSize) + } + + @Test + fun `SecvHeader should include magic bytes`() { + // Given + val header = SecvFileFormat.SecvHeader( + version = 1, + chunkSize = 1024, + totalChunks = 10L, + originalSize = 10240L, + finalChunkPlaintextSize = 240 + ) + + // When + val bytes = header.toByteArray() + val magic = bytes.copyOfRange(0, 4) + + // Then + val magicString = String(magic, Charsets.US_ASCII) + assertEquals("SECV", magicString, "Header should start with SECV magic bytes") + } + + @Test + fun `SecvHeader fromByteArray should validate magic bytes`() { + // Given + val invalidBytes = ByteArray(SecvFileFormat.HEADER_SIZE) + invalidBytes[0] = 'B'.code.toByte() + invalidBytes[1] = 'A'.code.toByte() + invalidBytes[2] = 'D'.code.toByte() + invalidBytes[3] = '!'.code.toByte() + + // When/Then + assertFailsWith { + SecvFileFormat.SecvHeader.fromByteArray(invalidBytes) + } + } + + @Test + fun `SecvHeader fromByteArray should reject undersized buffer`() { + // Given + val tooSmall = ByteArray(32) + + // When/Then + assertFailsWith { + SecvFileFormat.SecvHeader.fromByteArray(tooSmall) + } + } + + @Test + fun `SecvHeader should handle maximum values`() { + // Given + val header = SecvFileFormat.SecvHeader( + version = Short.MAX_VALUE, + chunkSize = Int.MAX_VALUE, + totalChunks = Long.MAX_VALUE, + originalSize = Long.MAX_VALUE, + finalChunkPlaintextSize = Int.MAX_VALUE + ) + + // When + val bytes = header.toByteArray() + val deserialized = SecvFileFormat.SecvHeader.fromByteArray(bytes) + + // Then + assertEquals(header.version, deserialized.version) + assertEquals(header.chunkSize, deserialized.chunkSize) + assertEquals(header.totalChunks, deserialized.totalChunks) + assertEquals(header.originalSize, deserialized.originalSize) + assertEquals(header.finalChunkPlaintextSize, deserialized.finalChunkPlaintextSize) + } + + @Test + fun `SecvHeader should handle zero values`() { + // Given + val header = SecvFileFormat.SecvHeader( + version = 0, + chunkSize = 0, + totalChunks = 0L, + originalSize = 0L, + finalChunkPlaintextSize = 0 + ) + + // When + val bytes = header.toByteArray() + val deserialized = SecvFileFormat.SecvHeader.fromByteArray(bytes) + + // Then + assertEquals(header.version, deserialized.version) + assertEquals(header.chunkSize, deserialized.chunkSize) + assertEquals(header.totalChunks, deserialized.totalChunks) + assertEquals(header.originalSize, deserialized.originalSize) + assertEquals(header.finalChunkPlaintextSize, deserialized.finalChunkPlaintextSize) + } +} diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFormatValidationTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFormatValidationTest.kt new file mode 100644 index 0000000..ac42c2c --- /dev/null +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFormatValidationTest.kt @@ -0,0 +1,338 @@ +package com.darkrockstudios.app.securecamera.security.streaming + +import com.darkrockstudios.app.securecamera.preferences.HashedPin +import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.File +import java.io.RandomAccessFile +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * Tests for SECV format validation and corruption detection. + */ +class SecvFormatValidationTest { + + private lateinit var testDir: File + private lateinit var encryptionScheme: TestEncryptionScheme + + @Before + fun setup() { + testDir = File(System.getProperty("java.io.tmpdir"), "secv-validation-test-${System.currentTimeMillis()}") + testDir.mkdirs() + encryptionScheme = TestEncryptionScheme() + } + + @After + fun cleanup() { + testDir.deleteRecursively() + } + + @Test + fun `should reject file with invalid magic bytes`() = runTest { + // Given - Create a valid encrypted file + val plaintext = ByteArray(100) { it.toByte() } + val encryptedFile = File(testDir, "test.secv") + + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize = 1024) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // When - Corrupt the magic bytes + RandomAccessFile(encryptedFile, "rw").use { raf -> + raf.seek(0) + raf.write("BAAD".toByteArray()) + } + + // Then - Should fail to open + assertFailsWith { + ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + } + } + + @Test + fun `should reject file with unsupported version`() = runTest { + // Given - Create a valid encrypted file + val plaintext = ByteArray(100) { it.toByte() } + val encryptedFile = File(testDir, "test.secv") + + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize = 1024) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // When - Change version to unsupported value + RandomAccessFile(encryptedFile, "rw").use { raf -> + raf.seek(4) // Skip magic + raf.writeShort(99) // Invalid version + } + + // Then - Should fail to open + assertFailsWith { + ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + } + } + + @Test + fun `should reject file that is too small`() = runTest { + // Given - Create a file smaller than header size + val tooSmall = File(testDir, "too-small.secv") + tooSmall.writeBytes(ByteArray(32)) // Only 32 bytes, need 64 + + // When/Then + assertFailsWith { + ChunkedStreamingDecryptor(tooSmall, encryptionScheme) + } + } + + @Test + fun `should reject non-existent file`() = runTest { + // Given + val nonExistent = File(testDir, "does-not-exist.secv") + + // When/Then + assertFailsWith { + ChunkedStreamingDecryptor(nonExistent, encryptionScheme) + } + } + + @Test + fun `should handle chunk boundary edge cases`() = runTest { + // Given - Data that aligns exactly with chunk boundaries + val chunkSize = 100 + val plaintext = ByteArray(chunkSize * 3) { it.toByte() } + val encryptedFile = File(testDir, "test.secv") + + // When + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + + // Read exactly at chunk boundaries + val buffer1 = ByteArray(chunkSize) + decryptor.read(0, buffer1, 0, chunkSize) // First chunk + + val buffer2 = ByteArray(chunkSize) + decryptor.read(chunkSize.toLong(), buffer2, 0, chunkSize) // Second chunk + + val buffer3 = ByteArray(chunkSize) + decryptor.read((chunkSize * 2).toLong(), buffer3, 0, chunkSize) // Third chunk + + decryptor.close() + + // Then + assertEquals(plaintext.copyOfRange(0, chunkSize).toList(), buffer1.toList()) + assertEquals(plaintext.copyOfRange(chunkSize, chunkSize * 2).toList(), buffer2.toList()) + assertEquals(plaintext.copyOfRange(chunkSize * 2, chunkSize * 3).toList(), buffer3.toList()) + } + + @Test + fun `should handle reading single byte at a time`() = runTest { + // Given + val plaintext = ByteArray(100) { it.toByte() } + val encryptedFile = File(testDir, "test.secv") + + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize = 30) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // When - Read byte by byte + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + val decrypted = ByteArray(plaintext.size) + for (i in plaintext.indices) { + decryptor.read(i.toLong(), decrypted, i, 1) + } + decryptor.close() + + // Then + assertEquals(plaintext.toList(), decrypted.toList()) + } + + @Test + fun `should handle very small chunk size`() = runTest { + // Given - Very small chunks (1 byte) + val chunkSize = 1 + val plaintext = ByteArray(10) { it.toByte() } + val encryptedFile = File(testDir, "test.secv") + + // When + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + val decrypted = ByteArray(plaintext.size) + decryptor.read(0, decrypted, 0, plaintext.size) + decryptor.close() + + // Then + assertEquals(plaintext.toList(), decrypted.toList()) + + // Verify file structure + RandomAccessFile(encryptedFile, "r").use { raf -> + val headerBytes = ByteArray(SecvFileFormat.HEADER_SIZE) + raf.readFully(headerBytes) + val header = SecvFileFormat.SecvHeader.fromByteArray(headerBytes) + + assertEquals(10L, header.totalChunks) // 10 chunks of 1 byte each + assertEquals(1, header.chunkSize) + assertEquals(1, header.finalChunkPlaintextSize) + } + } + + @Test + fun `should handle very large chunk size`() = runTest { + // Given - Chunk size larger than data + val chunkSize = 10_000 + val plaintext = ByteArray(100) { it.toByte() } + val encryptedFile = File(testDir, "test.secv") + + // When + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + val decrypted = ByteArray(plaintext.size) + decryptor.read(0, decrypted, 0, plaintext.size) + decryptor.close() + + // Then + assertEquals(plaintext.toList(), decrypted.toList()) + + // Verify file structure - should be single chunk + RandomAccessFile(encryptedFile, "r").use { raf -> + val headerBytes = ByteArray(SecvFileFormat.HEADER_SIZE) + raf.readFully(headerBytes) + val header = SecvFileFormat.SecvHeader.fromByteArray(headerBytes) + + assertEquals(1L, header.totalChunks) + assertEquals(100, header.finalChunkPlaintextSize) + } + } + + @Test + fun `should validate chunk offsets are calculated correctly`() = runTest { + // Given + val chunkSize = 1024 + val plaintext = ByteArray(5000) { it.toByte() } // 5 chunks + val encryptedFile = File(testDir, "test.secv") + + // When + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // Then - Manually verify file structure + RandomAccessFile(encryptedFile, "r").use { raf -> + // Read header + val headerBytes = ByteArray(SecvFileFormat.HEADER_SIZE) + raf.readFully(headerBytes) + val header = SecvFileFormat.SecvHeader.fromByteArray(headerBytes) + + assertEquals(5L, header.totalChunks) + + // Verify each chunk is at the expected offset + for (chunkIdx in 0 until header.totalChunks) { + val expectedOffset = SecvFileFormat.calculateChunkOffset(chunkIdx, chunkSize) + raf.seek(expectedOffset) + + // Read IV (should be 12 bytes) + val iv = ByteArray(SecvFileFormat.IV_SIZE) + val ivRead = raf.read(iv) + assertEquals(SecvFileFormat.IV_SIZE, ivRead, "IV should be 12 bytes at chunk $chunkIdx") + + // Verify we can read the ciphertext + val isFinalChunk = (chunkIdx == header.totalChunks - 1) + val plaintextSize = if (isFinalChunk) header.finalChunkPlaintextSize else chunkSize + val ciphertextSize = plaintextSize + SecvFileFormat.AUTH_TAG_SIZE + + val ciphertext = ByteArray(ciphertextSize) + val cipherRead = raf.read(ciphertext) + assertEquals(ciphertextSize, cipherRead, "Ciphertext size should match at chunk $chunkIdx") + } + } + } + + @Test + fun `file size should match expected format`() = runTest { + // Given + val chunkSize = 1000 + val plaintext = ByteArray(3500) { it.toByte() } // 3 full chunks + 500 byte final chunk + val encryptedFile = File(testDir, "test.secv") + + // When + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // Then - Calculate expected size + val fullChunks = 3 + val finalChunkSize = 500 + val expectedSize = SecvFileFormat.HEADER_SIZE + // 64 + (fullChunks * (chunkSize + 28)) + // 3 * 1028 = 3084 + (finalChunkSize + 28) // 528 + // Total: 64 + 3084 + 528 = 3676 + + assertEquals(expectedSize.toLong(), encryptedFile.length(), "File size should match expected format") + } + + @Test + fun `decryptor should cache chunks efficiently`() = runTest { + // Given + val chunkSize = 100 + val plaintext = ByteArray(500) { it.toByte() } + val encryptedFile = File(testDir, "test.secv") + + val encryptor = ChunkedStreamingEncryptor(encryptedFile, encryptionScheme, chunkSize) + encryptor.write(plaintext, 0, plaintext.size) + encryptor.close() + + // When - Read same chunk multiple times + val decryptor = ChunkedStreamingDecryptor(encryptedFile, encryptionScheme) + + val buffer1 = ByteArray(10) + decryptor.read(50, buffer1, 0, 10) // Chunk 0 + + val buffer2 = ByteArray(10) + decryptor.read(55, buffer2, 0, 10) // Same chunk 0 (should use cache) + + val buffer3 = ByteArray(10) + decryptor.read(150, buffer3, 0, 10) // Chunk 1 (different chunk) + + decryptor.close() + + // Then - Verify correct data was read + assertEquals(plaintext.copyOfRange(50, 60).toList(), buffer1.toList()) + assertEquals(plaintext.copyOfRange(55, 65).toList(), buffer2.toList()) + assertEquals(plaintext.copyOfRange(150, 160).toList(), buffer3.toList()) + } + + /** + * Simple test encryption scheme that uses a fixed key for testing. + */ + private class TestEncryptionScheme : EncryptionScheme { + private val testKey = ByteArray(32) { it.toByte() } + + override suspend fun getDerivedKey(): ByteArray = testKey.copyOf() + + // Unused methods for this test + override suspend fun encryptToFile(plain: ByteArray, targetFile: File) = error("Not used") + override suspend fun encryptToFile(plain: ByteArray, keyBytes: ByteArray, targetFile: File) = error("Not used") + override suspend fun encrypt(plain: ByteArray, keyBytes: ByteArray): ByteArray = error("Not used") + override suspend fun encryptWithKeyAlias(plain: ByteArray, keyAlias: String): ByteArray = error("Not used") + override suspend fun decryptWithKeyAlias(encrypted: ByteArray, keyAlias: String): ByteArray = error("Not used") + override suspend fun decryptFile(encryptedFile: File): ByteArray = error("Not used") + override suspend fun deriveAndCacheKey(plainPin: String, hashedPin: HashedPin) = error("Not used") + override suspend fun deriveKey(plainPin: String, hashedPin: HashedPin): ByteArray = error("Not used") + override fun evictKey() = Unit + override suspend fun createKey(plainPin: String, hashedPin: HashedPin) = error("Not used") + override suspend fun securityFailureReset() = error("Not used") + override fun activatePoisonPill(oldPin: HashedPin?) = Unit + override fun getStreamingCapability(): StreamingEncryptionScheme? = null + } +} diff --git a/docs/Video Encryption.md b/docs/Video Encryption.md index 1b8a4c7..b42cfda 100644 --- a/docs/Video Encryption.md +++ b/docs/Video Encryption.md @@ -44,48 +44,58 @@ Post-recording encryption is a pragmatic trade-off. The temp file exists unencry storage which is already protected by Android FBE when the device is locked. This is the real security boundary - not file deletion. -## SECV File Format +## SECV File Format (Version 1) ``` +--------------------------------------------------+ -| ENCRYPTED CHUNKS | -+--------------------------------------------------+ -| Chunk 0: [IV 12B][Ciphertext][Auth Tag 16B] | -| Chunk 1: [IV 12B][Ciphertext][Auth Tag 16B] | -| ... | -+--------------------------------------------------+ -| CHUNK INDEX TABLE | -| (12 bytes per chunk) | -+--------------------------------------------------+ -| Chunk 0: Offset (8) + Encrypted Size (4) | -| Chunk 1: Offset (8) + Encrypted Size (4) | -| ... | -| Chunk N: Offset (8) + Encrypted Size (4) | -+--------------------------------------------------+ -| TRAILER (64 bytes) | +| HEADER (64 bytes) | +--------------------------------------------------+ | Magic: "SECV" | 4 bytes | -| Version | 2 bytes (uint16) | +| Version: 1 | 2 bytes (uint16) | | Chunk Size | 4 bytes (uint32) | | Total Chunks | 8 bytes (uint64) | | Original Size | 8 bytes (uint64) | -| Reserved | 38 bytes | +| Final Chunk Size | 4 bytes (uint32) | +| Reserved | 34 bytes | +--------------------------------------------------+ +| ENCRYPTED CHUNKS | ++--------------------------------------------------+ +| Chunk 0: [IV 12B][Ciphertext][Auth Tag 16B] | +| Chunk 1: [IV 12B][Ciphertext][Auth Tag 16B] | +| ... | +| Chunk N-1 (final): [IV 12B][Ciphertext][Tag] | ++--------------------------------------------------+ +``` + +### Chunk Offset Calculation + +Since AES-GCM preserves plaintext size (no padding), all full chunks are identical size: + +``` +Full chunk encrypted size = chunk_size + 28 bytes (12 IV + 16 auth tag) +Chunk offset = 64 + (chunk_index × (chunk_size + 28)) +``` + +Only the final chunk may be smaller (if video size isn't a multiple of chunk_size): + +``` +Final chunk encrypted size = final_chunk_plaintext_size + 28 bytes ``` ### Design Rationale -**Trailer format (metadata at end)**: Placing the trailer and index table at the end eliminates the need to rewrite the -entire file when encryption completes. In the previous approach (v1), chunks were written first, then the entire file -had to be read back into memory and shifted forward to make room for the header - causing memory spikes and potential -OOM crashes with large videos (2GB+). With the trailer format, chunks are written starting at offset 0, and metadata is -simply appended at the end. This makes encryption faster, more memory-efficient, and crash-resistant. +**Header pre-allocation**: The encryptor writes 64 zero bytes at the start, then writes chunks. On close, it seeks back +to position 0 and fills in the header with final metadata (total chunks, original size, final chunk size). This approach +allows all writing to happen in-place, no shifting once the chunks are written. -**Fixed trailer size (64 bytes)**: Allows quick validation and metadata extraction without parsing variable-length +**Fixed header size (64 bytes)**: Allows quick validation and metadata extraction without parsing variable-length structures. -**Chunk index table**: Enables O(1) seeking. To play from position X, we calculate which chunk contains X, look up its -offset in the index, and decrypt just that chunk. +**Final chunk plaintext size**: Since the last chunk may be smaller, we store its plaintext size in the header. This +allows the decryptor to read the correct number of bytes for the final chunk. + +**O(1) seeking**: To play from position X, we calculate which chunk contains X using `chunk_index = X / chunk_size`, +calculate its offset using `64 + (chunk_index × (chunk_size + 28))`, and decrypt just that chunk. **Per-chunk IV**: Each chunk gets a fresh 12-byte IV from `SecureRandom`. This prevents nonce reuse even across millions of chunks. @@ -110,6 +120,31 @@ ciphertext = AES-GCM-Encrypt(key, iv, plaintext_chunk) stored = iv || ciphertext || auth_tag ``` +### Seeking to Position X + +To decrypt data at plaintext position X (byte offset): + +```kotlin +// 1. Calculate which chunk contains position X +chunk_index = X / chunk_size +offset_in_chunk = X % chunk_size + +// 2. Calculate file offset for that chunk +file_offset = 64 + (chunk_index × (chunk_size+28)) + +// 3. Determine encrypted size +if (chunk_index == total_chunks - 1) { + // Final chunk + encrypted_size = final_chunk_plaintext_size + 28 +} else { + // Full chunk + encrypted_size = chunk_size + 28 +} + +// 4. Read and decrypt chunk at file_offset +// 5. Extract bytes starting at offset_in_chunk +``` + ## Playback Architecture ```mermaid From 19033daaf298e219add3ded76f9aca91fcc9ec69 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 27 Jan 2026 23:11:07 -0800 Subject: [PATCH 09/47] typo --- docs/Video Encryption.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Video Encryption.md b/docs/Video Encryption.md index b42cfda..904cfeb 100644 --- a/docs/Video Encryption.md +++ b/docs/Video Encryption.md @@ -216,7 +216,7 @@ key is never written to disk in plaintext. ## File Extension Encrypted videos use the `.secv` extension (Secure Encrypted Camera Video). The gallery recognizes only `.secv` files -as ready-to-play videos. It detects unencrypted mp4, and partially encrypted `secv.encryption` files in order to show +as ready-to-play videos. It detects unencrypted mp4, and partially encrypted `secv.encrypting` files in order to show encryption status. ## Future Considerations From 8b1087468c94eb761a56511e4b9dbdad3d1cb981 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 27 Jan 2026 23:15:54 -0800 Subject: [PATCH 10/47] Library upgrades --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 88f7881..7b605a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,10 +18,10 @@ accompanistPermissions = "0.37.3" coreSplashscreen = "1.2.0" lifecycleViewModel = "2.10.0" materialIconsExtended = "1.7.8" -mockk = "1.14.7" +mockk = "1.14.9" kim = "0.26.2" kotlin = "2.3.0" -kotlinx-serialization = "1.9.0" +kotlinx-serialization = "1.10.0" runtimeLivedata = "1.10.1" coreKtx = "1.17.0" junit = "4.13.2" @@ -36,7 +36,7 @@ cryptographyBom = "0.5.0" datastorePreferences = "1.2.0" timber = "5.0.1" zoomable = "2.9.0" -media3 = "1.5.1" +media3 = "1.9.1" cameraCamera2 = "1.5.2" cameraView = "1.5.2" foundation = "1.10.1" From fbd1cdf10fae5d82e4f71fd44d95f66374822b5d Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 27 Jan 2026 23:25:47 -0800 Subject: [PATCH 11/47] Fix camera showing up twice in back stack --- .../app/securecamera/auth/PinVerificationContent.kt | 2 +- .../app/securecamera/auth/PinVerificationViewModel.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 b23c90c..f21a9a7 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 @@ -101,7 +101,7 @@ fun PinVerificationContent( pin = pin, returnKey = returnKey, onNavigate = { destKey -> - navController.navigateFromBase(Camera, destKey) + navController.navigateFromBase(Camera, destKey, launchSingleTop = true) }, 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 72cc3d0..e7a315f 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 @@ -125,8 +125,8 @@ class PinVerificationViewModel( onNavigate(returnKey) } } else { - val newFailedAttempts = authRepository.getFailedAttempts() - val remainingBackoff = authRepository.calculateRemainingBackoffSeconds() + val newFailedAttempts = authRepository.getFailedAttempts() + val remainingBackoff = authRepository.calculateRemainingBackoffSeconds() val isBackoffActive = remainingBackoff > 0 withContext(Dispatchers.Main) { From ffcc9bd01689dc28f36d14fda352ccfb3ee441cd Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 27 Jan 2026 23:35:00 -0800 Subject: [PATCH 12/47] Release prep --- fastlane/metadata/android/en-US/changelogs/26.txt | 9 +++++++++ gradle/libs.versions.toml | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/26.txt diff --git a/fastlane/metadata/android/en-US/changelogs/26.txt b/fastlane/metadata/android/en-US/changelogs/26.txt new file mode 100644 index 0000000..cead2c9 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/26.txt @@ -0,0 +1,9 @@ +What's New + - Video recording with encrypted storage (SECV format) + - Video playback and sharing support + - New camera controls layout with rotating UI + +Fixes + - Fixed PIN verification sometimes being disabled + - Fixed gallery not showing the correct item when tapped + - Fixed app not exiting properly when backing out of PIN screen \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b605a2..5e1dddb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -versionCode = "25" -versionName = "3.5.4" +versionCode = "26" +versionName = "4.0.0" targetSdk = "36" compileSdk = "36" From cc2c0f812aaad2440c950b41c3e49205d5777af4 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 27 Jan 2026 23:35:20 -0800 Subject: [PATCH 13/47] New Crowdin updates (#28) * New translations strings.xml (French) * New translations strings.xml (Spanish) * New translations strings.xml (German) * New translations strings.xml (Italian) * New translations strings.xml (Ukrainian) * New translations strings.xml (Chinese Simplified) * New translations strings.xml (English) * Update source file strings.xml * Update source file strings.xml * New translations strings.xml (French) * New translations strings.xml (Spanish) * New translations strings.xml (German) * New translations strings.xml (Italian) * New translations strings.xml (Ukrainian) * New translations strings.xml (Chinese Simplified) * New translations strings.xml (English) --- app/src/main/res/values-de-rDE/strings.xml | 46 ++++++++++++++++++++++ app/src/main/res/values-en-rUS/strings.xml | 46 ++++++++++++++++++++++ app/src/main/res/values-es-rES/strings.xml | 46 ++++++++++++++++++++++ app/src/main/res/values-fr-rFR/strings.xml | 46 ++++++++++++++++++++++ app/src/main/res/values-it-rIT/strings.xml | 46 ++++++++++++++++++++++ app/src/main/res/values-uk-rUA/strings.xml | 46 ++++++++++++++++++++++ app/src/main/res/values-zh-rCN/strings.xml | 46 ++++++++++++++++++++++ 7 files changed, 322 insertions(+) diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index ff12d3b..cf06b2c 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -49,6 +49,16 @@ Fotos teilen Fotos löschen Kein Foto ausgewählt + Keine Medien ausgewählt + + Video + Video nicht gefunden + Video + Video wird geladen… + Lade… + Fehler beim Laden des Videos + Fehler beim Abspielen des Videos + Video… verschlüsseln Nicht autorisiert Als Decoy markieren Als Köder aufheben @@ -85,6 +95,18 @@ Sitzungsstatus Zeigt an, wenn SnapSafe aktiv ist Schließen + + Video-Verschlüsselung + Videoverschlüsselungsfortschritt anzeigen + Video verschlüsseln + Videos verschlüsseln + Vorbereitung… + Verschlüsselung: %1$d%% + %1$d von %2$d - %3$d%% + Verschlüsselung fehlgeschlagen + Verschlüsselung abgebrochen + Abbrechen + Alle abbrechen Galerie %1$d ausgewählt @@ -97,6 +119,7 @@ Ausgewählte teilen Alle auswählen Ist Decoy Foto + Video Weitere Optionen Einstellungen @@ -111,6 +134,13 @@ Kamera-Berechtigungen sind erforderlich. Einstellungen öffnen Foto machen + Foto + Video + Aufnahmemodus wechseln + Aufnahme starten + Aufnahme stoppen + Aufnahme + Die Berechtigung zur Aufnahme von Audio wird für Video benötigt. Einstellungen Zurück @@ -139,6 +169,22 @@ 1 Minute 5 Minuten 10 Minuten + Mediensicherheit + Sicherheitsstärke nach Medientyp + Mediensicherheit + Fotos + 10/10 + Fotos, die mit SnapSafe aufgenommen und gespeichert werden, sind so sicher wie technisch möglich + . Sie werden nie unverschlüsselt auf die Festplatte geschrieben, auch nicht für kurze Momente. + + Videos + 9/10 + Videos taken with SnapSafe are incredibly secure, however, due to a + limitation with Android\'s video recording, they are first written to SnapSafe\'s private encrypted directory + while they are being recorded. Immediately after the recording ends, they are themselves encrypted and are + thereafter just as secure as photos taken with SnapSafe. + + Verstanden Sicherheit zurücksetzen Lösche alle Daten und erstelle eine neue PIN diff --git a/app/src/main/res/values-en-rUS/strings.xml b/app/src/main/res/values-en-rUS/strings.xml index 9b00d38..618cc72 100644 --- a/app/src/main/res/values-en-rUS/strings.xml +++ b/app/src/main/res/values-en-rUS/strings.xml @@ -49,6 +49,16 @@ Share Photos Delete Photos No photo selected + No media selected + + Video + Video not found + Video + Loading video… + Loading… + Failed to load video + Error playing video + Encrypting video… Unauthorized Mark as Decoy Unmark as Decoy @@ -85,6 +95,18 @@ Session Status Shows when SnapSafe is active Close + + Video Encryption + Shows video encryption progress + Encrypting Video + Encrypting Videos + Preparing… + Encrypting: %1$d%% + %1$d of %2$d - %3$d%% + Encryption Failed + Encryption Cancelled + Cancel + Cancel All Gallery %1$d Selected @@ -97,6 +119,7 @@ Share Selected Select All Is Decoy Photo + Video More Options Settings @@ -111,6 +134,13 @@ Camera permissions are required. Open Settings Take Photo + Photo + Video + Switch capture mode + Start Recording + Stop Recording + Recording + Audio recording permission is required for video. Settings Back @@ -139,6 +169,22 @@ 1 Minute 5 Minutes 10 Minutes + Media Security + Security strength by media type + Media Security + Photos + 10/10 + Photos taken and stored with SnapSafe are as safe as is technically + possible. They are never written to the disk unencrypted, even for brief moments. + + Videos + 9/10 + Videos taken with SnapSafe are incredibly secure, however, due to a + limitation with Android\'s video recording, they are first written to SnapSafe\'s private encrypted directory + while they are being recorded. Immediately after the recording ends, they are themselves encrypted and are + thereafter just as secure as photos taken with SnapSafe. + + Got it Security Reset Wipe all data and create a new PIN diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 8b50201..a4356e0 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -49,6 +49,16 @@ Compartir fotos Eliminar fotos Ninguna foto seleccionada + Ningún medio seleccionado + + Vídeo + Video no encontrado + Vídeo + Cargando vídeo… + Cargando… + Error al cargar el vídeo + Error al reproducir el vídeo + Cifrando vídeo… No autorizado Marcar como señuelo Desmarcar como señuelo @@ -85,6 +95,18 @@ Estado de la sesión Muestra cuando SnapSafe está activo Cerrar + + Encriptación de vídeo + Muestra el progreso del cifrado de vídeo + Cifrando vídeo + Cifrando videos + Preparando… + Cifrando: %1$d%% + %1$d de %2$d - %3$d%% + Cifrado fallido + Cifrado cancelado + Cancelar + Cancelar todo Galería %1$d seleccionados @@ -97,6 +119,7 @@ Compartir seleccionados Seleccionar todo Es Foto señuelo + Vídeo Más opciones Ajustes @@ -111,6 +134,13 @@ Se requieren permisos de cámara. Abrir ajustes Tomar foto + Foto + Vídeo + Cambiar modo de captura + Iniciar grabación + Detener grabación + Grabando + Se requiere permiso de grabación de audio para el vídeo. Ajustes Atrás @@ -139,6 +169,22 @@ 1 minuto 5 Minutos 10 minutos + Seguridad de medios + Fortaleza de seguridad por tipo de medio + Seguridad de medios + Fotos + 10/10 + Las fotos tomadas y almacenadas con SnapSafe son tan seguras como técnicamente + posible. Nunca son escritos en el disco sin cifrar, ni siquiera por momentos breves. + + Vídeo + 9/10 + Los vídeos tomados con SnapSafe son increíblemente seguros, sin embargo, debido a una limitación + con la grabación de vídeo de Android, son escritos por primera vez en el directorio privado cifrado + de SnapSafe mientras están siendo grabados. Inmediatamente después de que la grabación termine, son encriptados ellos mismos y son + a partir de entonces tan seguros como las fotos tomadas con SnapSafe. + + Entendido Restablecer seguridad Borrar todos los datos y crear un nuevo PIN diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 9eab7ce..eea4420 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -49,6 +49,16 @@ Partager des photos Supprimer les photos Aucune photo sélectionnée + Aucun média sélectionné + + Vidéo + Vidéo introuvable + Vidéo + Chargement de la vidéo… + Chargement de… + Impossible de charger la vidéo + Erreur lors de la lecture de la vidéo + Chiffrement de la vidéo… Non autorisé Marquer comme leurre Ne pas marquer comme leurre @@ -85,6 +95,18 @@ Statut de la session Montre quand SnapSafe est actif Fermer + + Chiffrement vidéo + Affiche la progression du cryptage vidéo + Cryptage de la vidéo + Chiffrement des vidéos + Préparation de… + Chiffrement : %1$d%% + %1$d de %2$d - %3$d%% + Échec du cryptage + Chiffrement annulé + Abandonner + Tout annuler Galerie %1$d sélectionné @@ -97,6 +119,7 @@ Partager la sélection Tout sélectionner Est une photo de leurre + Vidéo Plus d\'options Réglages @@ -111,6 +134,13 @@ Les autorisations de l\'appareil photo sont requises. Ouvrir les paramètres Prendre une photo + Photo + Vidéo + Changer de mode de capture + Démarrer l\'enregistrement + Arrêter l\'enregistrement + Enregistrement en cours + L\'autorisation d\'enregistrement audio est requise pour la vidéo. Réglages Précédent @@ -139,6 +169,22 @@ 1 minute 5 minutes 10 minutes + Sécurité des médias + Force de sécurité par type de média + Sécurité des médias + Photos + 10/10 + Les photos prises et stockées avec SnapSafe sont aussi sûres que techniquement + possible. Ils ne sont jamais écrits sur le disque non chiffrés, même pour de brefs instants. + + Vidéos + 9/10 + Les vidéos prises avec SnapSafe sont incroyablement sécurisées, cependant, en raison d\'une limitation + avec l\'enregistrement vidéo d\'Android, elles sont d\'abord écrites dans le répertoire privé chiffré de SnapSafe + pendant qu\'elles sont enregistrées. Immédiatement après la fin de l\'enregistrement, ils sont eux-mêmes chiffrés et sont + ensuite aussi sécurisés que les photos prises avec SnapSafe. + + Compris Réinitialisation de la sécurité Effacer toutes les données et créer un nouveau code PIN diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index 2b9fc69..d938f18 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -49,6 +49,16 @@ Condividi Foto Elimina Foto Nessuna foto selezionata + Nessun media selezionato + + Video + Video non trovato + Video + Caricamento video… + Caricamento… + Impossibile caricare il video + Errore nella riproduzione del video + Cifratura video… Non Autorizzato Segna come Decoy Deseleziona come Decoy @@ -85,6 +95,18 @@ Stato Sessione Mostra quando SnapSafe è attivo Chiudi + + Crittografia Video + Mostra l\'avanzamento della crittografia video + Cifratura Video + Cifratura Video + Preparazione… + Cifratura: %1$d%% + %1$d di %2$d - %3$d%% + Cifratura Non Riuscita + Cifratura Annullata + Annulla + Annulla Tutto Galleria %1$d Selezionato @@ -97,6 +119,7 @@ Condividi Selezionati Seleziona Tutto È Decoy Photo + Video Altre Opzioni Impostazioni @@ -111,6 +134,13 @@ Sono richiesti i permessi della fotocamera. Apri Impostazioni Scatta Foto + Foto + Video + Cambia modalità di acquisizione + Inizia La Registrazione + Interrompi Registrazione + Registrazione + È richiesto il permesso di registrazione audio per il video. Impostazioni Indietro @@ -139,6 +169,22 @@ 1 Minuto 5 Minuti 10 Minuti + Sicurezza Media + Forza di sicurezza per tipo di media + Sicurezza Media + Foto + 10/10 + Le foto scattate e memorizzate con SnapSafe sono sicure quanto tecnicamente + possibile. Non sono mai scritti sul disco non crittografati, anche per brevi momenti. + + Video + 9/10 + I video scattati con SnapSafe sono incredibilmente sicuri, tuttavia, a causa di una limitazione + con la registrazione video di Android, sono scritti per la prima volta nella directory privata crittografata di SnapSafe + durante la registrazione. Subito dopo la fine della registrazione, sono essi stessi crittografati e sono + in seguito altrettanto sicuri delle foto scattate con SnapSafe. + + Ho capito Ripristino Sicurezza Cancella tutti i dati e crea un nuovo PIN diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 7eac7b1..4b33635 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -49,6 +49,16 @@ Поділитися фотографіями Видалити фото Фото не обрано + Медіаелемент не вибрано + + Відео + Відео не знайдено + Відео + Завантаження відео… + Завантаження… + Не вдалося завантажити відео + Помилка при відтворенні відео + Encrypting video… Неавторизовано Позначити як обману Зняти позначку як обману @@ -85,6 +95,18 @@ Статус сесії Показувати, коли SnapSafe активний Закрити + + Шифрування відео + Відображає стан шифрування відео + Шифрування відео + Шифрування відео + Preparing… + Шифрування: %1$d%% + %1$d %2$d - %3$d%% + Шифрування не вдалось + Шифрування скасовано + Скасувати + Скасувати все Галерея %1$d Selected @@ -97,6 +119,7 @@ Поділитися вибраним Виділити все Є фотографією для обмани + Відео Інші налаштування Налаштування @@ -111,6 +134,13 @@ Потрібні дозволи камери. Відкрити налаштування Зробити фото + Фотографія + Відео + Перемкнути режим захоплення + Розпочати запис + Зупинити запис + Запис + Дозвіл на запис звуку необхідний для відео. Налаштування Відмінити @@ -139,6 +169,22 @@ 1 хвилина 5 хвилин 10 хвилин + Безпека медіа + Безпека потужності за типом медіа + Безпека медіа + Фотографії + 10/10 + Photos taken and stored with SnapSafe are as safe as is technically + possible. They are never written to the disk unencrypted, even for brief moments. + + Відео + 9/10 + Videos taken with SnapSafe are incredibly secure, however, due to a + limitation with Android\'s video recording, they are first written to SnapSafe\'s private encrypted directory + while they are being recorded. Immediately after the recording ends, they are themselves encrypted and are + thereafter just as secure as photos taken with SnapSafe. + + Зрозуміло Скидання Безпеки Стерти всі дані та створити новий PIN-код diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5e67df0..28f077e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -49,6 +49,16 @@ 分享照片 删除照片 未选择照片 + 未选择媒体 + + 视频 + 未找到视频 + 视频 + 加载视频… + 正在加载… + 加载视频失败 + 播放视频出错 + 加密视频… 未授权 标记为装饰 取消标记为装饰 @@ -85,6 +95,18 @@ 会话状态 SnapSafe 活动时显示 关闭 + + 视频加密 + 显示视频加密进度 + 加密视频 + 加密视频 + Preparing… + Encrypting: %1$d%% + %1$d of %2$d - %3$d%% + 加密失败 + 加密已取消 + 取消 + 全部取消 相册 已选择 %1$d @@ -97,6 +119,7 @@ 分享选定的 选择所有 是诱惑照片 + 视频 更多选项 设置 @@ -111,6 +134,13 @@ 相机权限是必需的。 打开设置 拍照 + 照片 + 视频 + 切换捕获模式 + 开始录制 + 停止录制 + 录制中 + 视频需要音频录制权限。 设置 后退 @@ -139,6 +169,22 @@ 1 分钟 5 分钟 10 分钟 + 媒体安全 + 按媒体类型分列的安全强度 + 媒体安全 + 照片 + 10/10 + Photos taken and stored with SnapSafe are as safe as is technically + possible. They are never written to the disk unencrypted, even for brief moments. + + 视频 + 9/10 + 与 SnapSafe 一起拍摄的视频是令人难以置信的安全性,不过,由于Android的视频录制受到 + 的限制, 他们在被记录时首先被写入SnapSafe的私有加密目录 + 。 录制结束后立即生效。 它们本身是加密的,其后是 + 与SnapSafe拍摄的照片一样安全。 + + 明白了 安全重置 清除所有数据并创建一个新的 PIN From 21ddc0941ca35d5663c128a2458d7513284ddfa6 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 27 Jan 2026 23:37:17 -0800 Subject: [PATCH 14/47] Change photomode icon --- .../app/securecamera/camera/BottomCameraControls.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 85d3d29..61ca027 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 @@ -52,7 +52,7 @@ fun BottomCameraControls( selected = captureMode == CaptureMode.PHOTO, onClick = { onModeChange(CaptureMode.PHOTO) }, enabled = !isRecording && !isLoading, - icon = Icons.Filled.Camera, + icon = Icons.Filled.Photo, contentDescription = stringResource(R.string.camera_mode_photo), iconRotation = iconRotation, ) From 30830539d9f63aa397a568ea32a341a64fa19101 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 28 Jan 2026 00:00:24 -0800 Subject: [PATCH 15/47] clean up --- docs/Video Encryption.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/Video Encryption.md b/docs/Video Encryption.md index 904cfeb..cf1a141 100644 --- a/docs/Video Encryption.md +++ b/docs/Video Encryption.md @@ -38,7 +38,6 @@ We considered several alternatives: - **Real-time encryption**: Not possible with CameraX's file-based recording API. - **Custom camera implementation**: Would sacrifice quality, stability, and features that CameraX provides. -- **Memory-mapped encryption**: Still requires the entire file in virtual memory space. Post-recording encryption is a pragmatic trade-off. The temp file exists unencrypted briefly, but only in app-private storage which is already protected by Android FBE when the device is locked. This is the real security boundary - not From effd240f12b2701c501417b2b39e6589cb470dee Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 28 Jan 2026 00:27:21 -0800 Subject: [PATCH 16/47] draft --- fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index acb295f..3fae9b1 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -9,7 +9,7 @@ platform :android do skip_upload_metadata: false, skip_upload_images: false, skip_upload_screenshots: false, - release_status: 'completed' + release_status: 'draft' ) end end From 2cf860f0f5d6cfdae177efda61e96b34dbe54dbb Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 28 Jan 2026 19:16:28 -0800 Subject: [PATCH 17/47] tweak wording --- docs/Video Encryption.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/Video Encryption.md b/docs/Video Encryption.md index cf1a141..c6743d6 100644 --- a/docs/Video Encryption.md +++ b/docs/Video Encryption.md @@ -40,8 +40,7 @@ We considered several alternatives: - **Custom camera implementation**: Would sacrifice quality, stability, and features that CameraX provides. Post-recording encryption is a pragmatic trade-off. The temp file exists unencrypted briefly, but only in app-private -storage which is already protected by Android FBE when the device is locked. This is the real security boundary - not -file deletion. +storage which is already protected by Android FBE when the device is locked. This should be sufficent as the window of vulnerability is brief. (_Only during recording, the video is encrypted immedately afterward._) ## SECV File Format (Version 1) From a5504ce663590f714685ef027f8ac34f63d773f8 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 28 Jan 2026 00:48:23 -0800 Subject: [PATCH 18/47] revert --- fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 3fae9b1..acb295f 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -9,7 +9,7 @@ platform :android do skip_upload_metadata: false, skip_upload_images: false, skip_upload_screenshots: false, - release_status: 'draft' + release_status: 'completed' ) end end From e3d8bcff5570e3ba69211f193948efc075218e2c Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 28 Jan 2026 23:53:12 -0800 Subject: [PATCH 19/47] Fix face tracking --- .../app/securecamera/obfuscation/MlFacialDetection.kt | 1 + .../app/securecamera/obfuscation/AndroidFacialDetection.kt | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/src/full/kotlin/com/darkrockstudios/app/securecamera/obfuscation/MlFacialDetection.kt b/app/src/full/kotlin/com/darkrockstudios/app/securecamera/obfuscation/MlFacialDetection.kt index 0549b70..edd0f6d 100644 --- a/app/src/full/kotlin/com/darkrockstudios/app/securecamera/obfuscation/MlFacialDetection.kt +++ b/app/src/full/kotlin/com/darkrockstudios/app/securecamera/obfuscation/MlFacialDetection.kt @@ -89,6 +89,7 @@ class MlFacialDetection : FacialDetection { rotationDegrees = rotation, isFrontCamera = isFrontCamera, previewSizePx = IntSize(previewWidth, previewHeight), + fillScale = false, ) val rects = foundFaces.map { face -> mapper(RectF(face.boundingBox)) diff --git a/app/src/oss/kotlin/com/darkrockstudios/app/securecamera/obfuscation/AndroidFacialDetection.kt b/app/src/oss/kotlin/com/darkrockstudios/app/securecamera/obfuscation/AndroidFacialDetection.kt index f8cb6f0..b9f9d38 100644 --- a/app/src/oss/kotlin/com/darkrockstudios/app/securecamera/obfuscation/AndroidFacialDetection.kt +++ b/app/src/oss/kotlin/com/darkrockstudios/app/securecamera/obfuscation/AndroidFacialDetection.kt @@ -1,10 +1,6 @@ package com.darkrockstudios.app.securecamera.obfuscation -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.PointF -import android.graphics.Rect -import android.graphics.RectF +import android.graphics.* import android.media.FaceDetector import androidx.camera.core.ImageProxy import androidx.core.graphics.createBitmap @@ -146,6 +142,7 @@ class AndroidFacialDetection : FacialDetection { rotationDegrees = 0, // already rotated into display basis isFrontCamera = isFrontCamera, previewSizePx = androidx.compose.ui.unit.IntSize(previewWidth, previewHeight), + fillScale = false, ) val rects = ArrayList(found) From e30a47240872b73738f147d300cbca14b9358e8e Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 28 Jan 2026 23:54:07 -0800 Subject: [PATCH 20/47] Release prep --- fastlane/metadata/android/en-US/changelogs/27.txt | 10 ++++++++++ gradle/libs.versions.toml | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/27.txt diff --git a/fastlane/metadata/android/en-US/changelogs/27.txt b/fastlane/metadata/android/en-US/changelogs/27.txt new file mode 100644 index 0000000..e11bfff --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/27.txt @@ -0,0 +1,10 @@ +What's New + - Video recording with encrypted storage (SECV format) + - Video playback and sharing support + - New camera controls layout with rotating UI + +Fixes + - Fixed PIN verification sometimes being disabled + - Fixed gallery not showing the correct item when tapped + - Fixed app not exiting properly when backing out of PIN screen + - Face tracking not tracking faces \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5e1dddb..dc9eb47 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -versionCode = "26" -versionName = "4.0.0" +versionCode = "27" +versionName = "4.0.1" targetSdk = "36" compileSdk = "36" From 9ec12bcc6f37019f4017df65d3778391467ff58e Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 30 Jan 2026 17:40:42 -0800 Subject: [PATCH 21/47] lib updates --- gradle/libs.versions.toml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc9eb47..b43a662 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ minSdk = "29" javaVersion = "17" agp = "8.12.3" -workManager = "2.11.0" +workManager = "2.11.1" argon2kt = "1.6.0" bcrypt = "0.10.2" faceDetection = "16.1.7" @@ -22,12 +22,12 @@ mockk = "1.14.9" kim = "0.26.2" kotlin = "2.3.0" kotlinx-serialization = "1.10.0" -runtimeLivedata = "1.10.1" +runtimeLivedata = "1.10.2" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" -activityCompose = "1.12.2" -composeBom = "2026.01.00" +activityCompose = "1.12.3" +composeBom = "2026.01.01" navigation3 = "1.0.0" nav3Material = "1.0.0-SNAPSHOT" lifecycleViewmodelNav3 = "2.10.0" @@ -37,17 +37,16 @@ datastorePreferences = "1.2.0" timber = "5.0.1" zoomable = "2.9.0" media3 = "1.9.1" -cameraCamera2 = "1.5.2" -cameraView = "1.5.2" -foundation = "1.10.1" +camerax = "1.5.3" +foundation = "1.10.2" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } -androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraCamera2" } -androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "cameraView" } -androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "cameraView" } -androidx-camera-core = { module = "androidx.camera.viewfinder:viewfinder-core", version.ref = "cameraView" } -androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraCamera2" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } +androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "camerax" } +androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" } +androidx-camera-core = { module = "androidx.camera.viewfinder:viewfinder-core", version.ref = "camerax" } +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } androidx-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "foundation" } From 57977c00d0b7bfd7b80b8afc63989f46886711bc Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 30 Jan 2026 21:20:36 -0800 Subject: [PATCH 22/47] Allow alpha-numeric PINs --- .../auth/PinVerificationContent.kt | 2 +- .../auth/PinVerificationViewModel.kt | 19 ++++- .../introduction/IntroductionViewModel.kt | 4 + .../introduction/IntroductionViewModelImpl.kt | 16 +++- .../PinCreationContent.Preview.kt | 2 + .../introduction/PinCreationContent.kt | 84 ++++++++++++++++++- .../preferences/AppSettingsDataSource.kt | 11 +++ .../PreferencesAppSettingsDataSource.kt | 19 +++++ .../usecases/PinStrengthCheckUseCase.kt | 46 +++++++++- app/src/main/res/values/strings.xml | 7 ++ .../usecases/PinStrengthCheckUseCaseTest.kt | 51 +++++++++++ 11 files changed, 249 insertions(+), 12 deletions(-) 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 f21a9a7..91cec98 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 @@ -118,7 +118,7 @@ fun PinVerificationContent( visualTransformation = PasswordVisualTransformation(), singleLine = true, keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword, + keyboardType = if (uiState.isAlphanumericPinEnabled) KeyboardType.Password else KeyboardType.NumberPassword, imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( 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 e7a315f..851e9a5 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 @@ -8,12 +8,14 @@ import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.encryption.VideoEncryptionService import com.darkrockstudios.app.securecamera.gallery.vibrateDevice import com.darkrockstudios.app.securecamera.navigation.Introduction +import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource import com.darkrockstudios.app.securecamera.usecases.InvalidateSessionUseCase import com.darkrockstudios.app.securecamera.usecases.PinSizeUseCase import com.darkrockstudios.app.securecamera.usecases.SecurityResetUseCase import com.darkrockstudios.app.securecamera.usecases.VerifyPinUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -25,6 +27,7 @@ class PinVerificationViewModel( private val securityResetUseCase: SecurityResetUseCase, private val verifyPinUseCase: VerifyPinUseCase, private val pinSizeUseCase: PinSizeUseCase, + private val appSettingsDataSource: AppSettingsDataSource, ) : BaseViewModel() { override fun createState() = PinVerificationUiState() @@ -40,12 +43,15 @@ class PinVerificationViewModel( val remainingBackoff = authRepository.calculateRemainingBackoffSeconds() val isBackoffActive = remainingBackoff > 0 + val isAlphanumericEnabled = appSettingsDataSource.alphanumericPinEnabled.first() + _uiState.update { it.copy( failedAttempts = failedAttempts, remainingBackoffSeconds = remainingBackoff, isBackoffActive = isBackoffActive, - error = if (isBackoffActive) PinVerificationError.INVALID_PIN else PinVerificationError.NONE + error = if (isBackoffActive) PinVerificationError.INVALID_PIN else PinVerificationError.NONE, + isAlphanumericPinEnabled = isAlphanumericEnabled ) } @@ -76,7 +82,13 @@ class PinVerificationViewModel( fun validatePin(newPin: String): Boolean { val pinSize = pinSizeUseCase.getPinSizeRange() - return if (newPin.length <= pinSize.max() && newPin.all { char -> char.isDigit() }) { + val isAlphanumeric = uiState.value.isAlphanumericPinEnabled + val isValid = if (isAlphanumeric) { + newPin.all { it.isLetterOrDigit() } + } else { + newPin.all { it.isDigit() } + } + return if (newPin.length <= pinSize.max() && isValid) { clearError() true } else { @@ -173,5 +185,6 @@ data class PinVerificationUiState( val isVerifying: Boolean = false, val failedAttempts: Int = 0, val isBackoffActive: Boolean = false, - val remainingBackoffSeconds: Int = 0 + val remainingBackoffSeconds: Int = 0, + val isAlphanumericPinEnabled: Boolean = false ) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionViewModel.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionViewModel.kt index 54ec045..b083758 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionViewModel.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionViewModel.kt @@ -15,6 +15,8 @@ interface IntroductionViewModel { fun createPin(pin: String, confirmPin: String) fun toggleBiometricsRequired() fun toggleEphemeralKey() + fun toggleAlphanumericPin() + fun setShowAlphanumericHelpDialog(show: Boolean) } data class IntroductionUiState( @@ -27,4 +29,6 @@ data class IntroductionUiState( val currentPage: Int = 0, val isCreatingPin: Boolean = false, val pinSize: IntRange, + val alphanumericPinEnabled: Boolean = false, + val showAlphanumericHelpDialog: Boolean = false, ) \ No newline at end of file diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionViewModelImpl.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionViewModelImpl.kt index b84ddc1..6a72d91 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionViewModelImpl.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionViewModelImpl.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.* import androidx.lifecycle.viewModelScope import com.darkrockstudios.app.securecamera.BaseViewModel import com.darkrockstudios.app.securecamera.R +import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource import com.darkrockstudios.app.securecamera.security.HardwareSchemeConfig import com.darkrockstudios.app.securecamera.security.SecurityLevel import com.darkrockstudios.app.securecamera.security.SecurityLevelDetector @@ -27,6 +28,7 @@ class IntroductionViewModelImpl( private val pinStrengthCheck: PinStrengthCheckUseCase, private val createPinUseCase: CreatePinUseCase, private val pinSizeUseCase: PinSizeUseCase, + private val appSettingsDataSource: AppSettingsDataSource, ) : BaseViewModel(), IntroductionViewModel { override fun createState() = IntroductionUiState( @@ -106,7 +108,7 @@ class IntroductionViewModelImpl( return } - val strongPin = pinStrengthCheck.isPinStrongEnough(pin) + val strongPin = pinStrengthCheck.isPinStrongEnough(pin, uiState.value.alphanumericPinEnabled) if (strongPin.not()) { _uiState.update { it.copy(errorMessage = appContext.getString(R.string.pin_creation_error_weak_pin)) } return @@ -131,4 +133,16 @@ class IntroductionViewModelImpl( override fun toggleEphemeralKey() { _uiState.update { it.copy(ephemeralKey = it.ephemeralKey.not()) } } + + override fun toggleAlphanumericPin() { + val newValue = !uiState.value.alphanumericPinEnabled + _uiState.update { it.copy(alphanumericPinEnabled = newValue) } + viewModelScope.launch { + appSettingsDataSource.setAlphanumericPinEnabled(newValue) + } + } + + override fun setShowAlphanumericHelpDialog(show: Boolean) { + _uiState.update { it.copy(showAlphanumericHelpDialog = show) } + } } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/PinCreationContent.Preview.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/PinCreationContent.Preview.kt index 57c19c6..4ea0227 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/PinCreationContent.Preview.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/PinCreationContent.Preview.kt @@ -20,6 +20,8 @@ private class DummyIntroductionViewModel(initial: IntroductionUiState) : Introdu override fun createPin(pin: String, confirmPin: String) {} override fun toggleBiometricsRequired() {} override fun toggleEphemeralKey() {} + override fun toggleAlphanumericPin() {} + override fun setShowAlphanumericHelpDialog(show: Boolean) {} } @Preview(name = "Pin Creation", showBackground = true) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/PinCreationContent.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/PinCreationContent.kt index b435325..a199444 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/PinCreationContent.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/PinCreationContent.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.filled.Pin import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -85,11 +86,43 @@ fun PinCreationContent( modifier = Modifier.padding(bottom = 24.dp) ) + // Alpha-numeric PIN checkbox row + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = uiState.alphanumericPinEnabled, + onCheckedChange = { viewModel.toggleAlphanumericPin() }, + enabled = !uiState.isCreatingPin + ) + Text( + text = stringResource(R.string.pin_creation_alphanumeric_label), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { viewModel.setShowAlphanumericHelpDialog(true) } + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.HelpOutline, + contentDescription = stringResource(R.string.pin_creation_alphanumeric_help_description) + ) + } + } + // PIN input OutlinedTextField( value = pin, onValueChange = { newPin -> - if (newPin.length <= uiState.pinSize.max() && newPin.all { char -> char.isDigit() }) { + val isValid = if (uiState.alphanumericPinEnabled) { + newPin.all { char -> char.isLetterOrDigit() } + } else { + newPin.all { char -> char.isDigit() } + } + if (newPin.length <= uiState.pinSize.max() && isValid) { pin = newPin } }, @@ -97,7 +130,7 @@ fun PinCreationContent( label = { Text(stringResource(R.string.pin_creation_hint)) }, visualTransformation = if (pinVisible) VisualTransformation.None else PasswordVisualTransformation(), keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword, + keyboardType = if (uiState.alphanumericPinEnabled) KeyboardType.Password else KeyboardType.NumberPassword, imeAction = ImeAction.Next ), trailingIcon = { @@ -117,11 +150,27 @@ fun PinCreationContent( .padding(bottom = 16.dp) ) + // Short PIN warning + if (pin.isNotEmpty() && pin.length < 6) { + Text( + text = stringResource(R.string.pin_creation_short_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + // Confirm PIN input OutlinedTextField( value = confirmPin, onValueChange = { newConfirmPin -> - if (newConfirmPin.length <= uiState.pinSize.max() && newConfirmPin.all { char -> char.isDigit() }) { + val isValid = if (uiState.alphanumericPinEnabled) { + newConfirmPin.all { char -> char.isLetterOrDigit() } + } else { + newConfirmPin.all { char -> char.isDigit() } + } + if (newConfirmPin.length <= uiState.pinSize.max() && isValid) { confirmPin = newConfirmPin } }, @@ -129,7 +178,7 @@ fun PinCreationContent( label = { Text(stringResource(R.string.pin_creation_confirm_hint)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword, + keyboardType = if (uiState.alphanumericPinEnabled) KeyboardType.Password else KeyboardType.NumberPassword, imeAction = ImeAction.Done ), singleLine = true, @@ -186,4 +235,31 @@ fun PinCreationContent( title = R.string.pin_create_notification_rationale_title, text = R.string.pin_create_notification_rationale_text, ) + + // Alpha-numeric PIN help dialog + if (uiState.showAlphanumericHelpDialog) { + AlphanumericPinHelpDialog( + onDismiss = { viewModel.setShowAlphanumericHelpDialog(false) } + ) + } +} + +@Composable +private fun AlphanumericPinHelpDialog( + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(R.string.pin_alphanumeric_help_title)) + }, + text = { + Text(text = stringResource(R.string.pin_alphanumeric_help_message)) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.ok_button)) + } + } + ) } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/AppSettingsDataSource.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/AppSettingsDataSource.kt index 859699b..463560d 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/AppSettingsDataSource.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/AppSettingsDataSource.kt @@ -27,6 +27,12 @@ interface AppSettingsDataSource { val enableFaceTracking: Flow val enableFaceTrackingDefault: Boolean + /** + * Enable alpha-numeric PINs (letters + digits instead of digits only) + */ + val alphanumericPinEnabled: Flow + val alphanumericPinEnabledDefault: Boolean + /** * Get the session timeout preference */ @@ -60,6 +66,11 @@ interface AppSettingsDataSource { */ suspend fun setEnableFaceTracking(enable: Boolean) + /** + * Set the alpha-numeric PIN enabled preference + */ + suspend fun setAlphanumericPinEnabled(enabled: Boolean) + /** * Get the current failed PIN attempts count */ diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/PreferencesAppSettingsDataSource.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/PreferencesAppSettingsDataSource.kt index 86954d6..f4c20b7 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/PreferencesAppSettingsDataSource.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/PreferencesAppSettingsDataSource.kt @@ -36,6 +36,7 @@ class PreferencesAppSettingsDataSource( private val SANITIZE_FILE_NAME = booleanPreferencesKey("sanitize_file_name") private val SANITIZE_METADATA = booleanPreferencesKey("sanitize_metadata") private val FACE_TRACKING_ENABLED = booleanPreferencesKey("face_tracking_enabled") + private val ALPHANUMERIC_PIN_ENABLED = booleanPreferencesKey("alphanumeric_pin_enabled") private val FAILED_PIN_ATTEMPTS = stringPreferencesKey("failed_pin_attempts") private val LAST_FAILED_ATTEMPT_TIMESTAMP = stringPreferencesKey("last_failed_attempt_timestamp") private val SESSION_TIMEOUT = stringPreferencesKey("session_timeout") @@ -101,6 +102,15 @@ class PreferencesAppSettingsDataSource( } override val enableFaceTrackingDefault = true + /** + * Enable alpha-numeric PIN preference + */ + override val alphanumericPinEnabled: Flow = dataStore.data + .map { preferences -> + preferences[ALPHANUMERIC_PIN_ENABLED] ?: alphanumericPinEnabledDefault + } + override val alphanumericPinEnabledDefault = false + /** * Get the session timeout preference */ @@ -156,6 +166,15 @@ class PreferencesAppSettingsDataSource( } } + /** + * Set the alpha-numeric PIN enabled preference + */ + override suspend fun setAlphanumericPinEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[ALPHANUMERIC_PIN_ENABLED] = enabled + } + } + /** * Get the current failed PIN attempts count */ diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/PinStrengthCheckUseCase.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/PinStrengthCheckUseCase.kt index 2a4e97f..37a7cde 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/PinStrengthCheckUseCase.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/PinStrengthCheckUseCase.kt @@ -1,7 +1,15 @@ package com.darkrockstudios.app.securecamera.usecases class PinStrengthCheckUseCase { - fun isPinStrongEnough(pin: String): Boolean { + fun isPinStrongEnough(pin: String, isAlphanumeric: Boolean = false): Boolean { + return if (isAlphanumeric) { + isAlphanumericPinStrong(pin) + } else { + isNumericPinStrong(pin) + } + } + + private fun isNumericPinStrong(pin: String): Boolean { // Check if PIN is at least 4 digits long and contains only digits if (pin.length < 4 || !pin.all { it.isDigit() }) { return false @@ -25,21 +33,53 @@ class PinStrengthCheckUseCase { return false } - if (blackList.contains(pin)) { + if (numericBlackList.contains(pin)) { return false } return true } + private fun isAlphanumericPinStrong(pin: String): Boolean { + // At least 4 characters + if (pin.length < 4) return false + + // Must contain only letters and digits + if (!pin.all { it.isLetterOrDigit() }) return false + + // All same character check (case-insensitive) + if (pin.all { it.equals(pin[0], ignoreCase = true) }) return false + + // Check blacklist (case-insensitive) + if (alphanumericBlackList.any { it.equals(pin, ignoreCase = true) }) return false + + return true + } + companion object { /** * These are some of the most frequently chosen PINs in data leaks * that are not already covered by our other heuristics. */ - val blackList = listOf( + val numericBlackList = listOf( "1212", "6969", ) + + /** + * Common weak passwords that should be rejected for alphanumeric PINs. + */ + val alphanumericBlackList = listOf( + "password", + "qwerty", + "abc123", + "letmein", + "admin", + "welcome", + "monkey", + "dragon", + "master", + "login", + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d563c4..6819bc1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -360,6 +360,13 @@ Show PIN Hide PIN + + Enable alpha-numeric PIN + Learn about alpha-numeric PINs + Short PINs are less secure. Consider using 6+ characters. + Alpha-numeric PINs + Alpha-numeric PINs allow letters (a-z, A-Z) in addition to digits (0-9).\n\nThis dramatically increases security:\n\n- A 4-digit numeric PIN has 10,000 possible combinations\n- A 4-character alpha-numeric PIN has over 14 million combinations\n\nUsing letters and numbers together makes your PIN much harder to guess or brute-force. + Allow Notifications SnapSafe only uses notifications to keep you in control of work it may be doing in the background, or to inform you of the current security status. It is strongly diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/usecases/PinStrengthCheckUseCaseTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/usecases/PinStrengthCheckUseCaseTest.kt index 43b76c2..5090416 100644 --- a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/usecases/PinStrengthCheckUseCaseTest.kt +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/usecases/PinStrengthCheckUseCaseTest.kt @@ -73,4 +73,55 @@ class PinStrengthCheckUseCaseTest { assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("1212")) assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("6969")) } + + // Alphanumeric PIN tests + + @Test + fun `test valid alphanumeric PINs`() { + assertTrue(pinStrengthCheckUseCase.isPinStrongEnough("1337zjew", isAlphanumeric = true)) + assertTrue(pinStrengthCheckUseCase.isPinStrongEnough("MyP1n123", isAlphanumeric = true)) + assertTrue(pinStrengthCheckUseCase.isPinStrongEnough("abcd", isAlphanumeric = true)) + assertTrue(pinStrengthCheckUseCase.isPinStrongEnough("Test1", isAlphanumeric = true)) + // Numeric patterns are OK when part of alphanumeric + assertTrue(pinStrengthCheckUseCase.isPinStrongEnough("1234abcd", isAlphanumeric = true)) + } + + @Test + fun `test invalid alphanumeric PINs - blacklisted passwords`() { + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("password", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("PASSWORD", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("Password", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("qwerty", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("abc123", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("letmein", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("admin", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("welcome", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("monkey", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("dragon", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("master", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("login", isAlphanumeric = true)) + } + + @Test + fun `test invalid alphanumeric PINs - too short`() { + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("abc", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("ab", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("a", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("", isAlphanumeric = true)) + } + + @Test + fun `test invalid alphanumeric PINs - all same character`() { + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("aaaa", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("AAAA", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("AaAa", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("1111", isAlphanumeric = true)) + } + + @Test + fun `test invalid alphanumeric PINs - special characters`() { + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("pass!", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("test@123", isAlphanumeric = true)) + assertFalse(pinStrengthCheckUseCase.isPinStrongEnough("my-pin", isAlphanumeric = true)) + } } From c164a7b8ad7e5fee155271473b7d2719446d6e3a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 31 Jan 2026 18:44:57 -0800 Subject: [PATCH 23/47] reworked encrypted file names This removes all plain text time stamps, leaking less information --- .../app/securecamera/AppModule.kt | 6 + .../auth/PinVerificationContent.kt | 65 +++ .../auth/PinVerificationViewModel.kt | 46 +- .../app/securecamera/camera/CameraState.kt | 24 +- .../app/securecamera/camera/PhotoDef.kt | 13 +- .../camera/SecureImageRepository.kt | 97 +++- .../app/securecamera/camera/VideoDef.kt | 14 +- .../encryption/VideoEncryptionJob.kt | 1 + .../encryption/VideoEncryptionService.kt | 56 ++- .../securecamera/gallery/GalleryViewModel.kt | 10 +- .../metadata/MediaMetadataEntry.kt | 167 +++++++ .../securecamera/metadata/MetadataManager.kt | 460 ++++++++++++++++++ .../metadata/MetadataMigrationManager.kt | 268 ++++++++++ .../securecamera/metadata/SecmFileFormat.kt | 86 ++++ .../security/FileTimestampObfuscator.kt | 44 ++ .../streaming/VideoEncryptionHelper.kt | 5 +- .../viewphoto/ViewPhotoViewModel.kt | 4 +- app/src/main/res/values/strings.xml | 5 + 18 files changed, 1299 insertions(+), 72 deletions(-) create mode 100644 app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntry.kt create mode 100644 app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt create mode 100644 app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataMigrationManager.kt create mode 100644 app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/SecmFileFormat.kt create mode 100644 app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/FileTimestampObfuscator.kt diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/AppModule.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/AppModule.kt index 78a125d..72b6d94 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/AppModule.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/AppModule.kt @@ -11,10 +11,13 @@ import com.darkrockstudios.app.securecamera.gallery.GalleryViewModel import com.darkrockstudios.app.securecamera.import.ImportPhotosViewModel import com.darkrockstudios.app.securecamera.introduction.IntroductionViewModel import com.darkrockstudios.app.securecamera.introduction.IntroductionViewModelImpl +import com.darkrockstudios.app.securecamera.metadata.MetadataManager +import com.darkrockstudios.app.securecamera.metadata.MetadataMigrationManager import com.darkrockstudios.app.securecamera.obfuscation.ObfuscatePhotoViewModel import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource import com.darkrockstudios.app.securecamera.preferences.PreferencesAppSettingsDataSource import com.darkrockstudios.app.securecamera.security.DeviceInfoDataSource +import com.darkrockstudios.app.securecamera.security.FileTimestampObfuscator import com.darkrockstudios.app.securecamera.security.SecurityLevel import com.darkrockstudios.app.securecamera.security.SecurityLevelDetector import com.darkrockstudios.app.securecamera.security.pin.PinCrypto @@ -39,8 +42,11 @@ val appModule = module { factory { Clock.System } bind Clock::class factory { PreferencesAppSettingsDataSource(context = get()) } factoryOf(::DeviceInfoDataSource) + singleOf(::FileTimestampObfuscator) singleOf(::SecureImageRepository) + singleOf(::MetadataManager) + singleOf(::MetadataMigrationManager) single { AuthorizationRepository( preferences = get(), 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 91cec98..974291f 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 @@ -19,6 +19,8 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavKey import com.darkrockstudios.app.securecamera.R @@ -196,5 +198,68 @@ fun PinVerificationContent( } } + // Migration progress dialog - blocks user interaction until complete + if (uiState.isMigrating) { + DataMigrationDialog(uiState) + } + HandleUiEvents(viewModel.events, snackbarHostState, navController) } + +@Composable +private fun DataMigrationDialog(uiState: PinVerificationUiState) { + Dialog( + onDismissRequest = { /* Non-dismissible */ }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = MaterialTheme.shapes.large + ) { + Column( + modifier = Modifier + .padding(24.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.migration_dialog_title), + style = MaterialTheme.typography.titleLarge + ) + + Text( + text = stringResource( + R.string.migration_dialog_progress, + uiState.migrationProgress, + uiState.migrationTotal + ), + style = MaterialTheme.typography.bodyMedium + ) + + val progress = if (uiState.migrationTotal > 0) { + uiState.migrationProgress.toFloat() / uiState.migrationTotal.toFloat() + } else { + 0f + } + + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = stringResource(R.string.migration_dialog_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } +} 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 851e9a5..f374fdc 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 @@ -7,6 +7,8 @@ import com.darkrockstudios.app.securecamera.BaseViewModel import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.encryption.VideoEncryptionService import com.darkrockstudios.app.securecamera.gallery.vibrateDevice +import com.darkrockstudios.app.securecamera.metadata.MetadataManager +import com.darkrockstudios.app.securecamera.metadata.MetadataMigrationManager import com.darkrockstudios.app.securecamera.navigation.Introduction import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource import com.darkrockstudios.app.securecamera.usecases.InvalidateSessionUseCase @@ -28,6 +30,8 @@ class PinVerificationViewModel( private val verifyPinUseCase: VerifyPinUseCase, private val pinSizeUseCase: PinSizeUseCase, private val appSettingsDataSource: AppSettingsDataSource, + private val metadataManager: MetadataManager, + private val metadataMigrationManager: MetadataMigrationManager, ) : BaseViewModel() { override fun createState() = PinVerificationUiState() @@ -123,6 +127,12 @@ class PinVerificationViewModel( val isValid = verifyPinUseCase.verifyPin(pin) if (isValid) { + metadataManager.loadIndex() + + if (metadataMigrationManager.needsMigration()) { + runDataMigration() + } + // Recover any stranded temp video files immediately after auth // This ensures unencrypted videos are encrypted ASAP VideoEncryptionService.recoverStrandedFiles(appContext) @@ -171,6 +181,37 @@ class PinVerificationViewModel( } } + private suspend fun runDataMigration() { + val totalFiles = metadataMigrationManager.countFilesToMigrate() + withContext(Dispatchers.Main) { + _uiState.update { + it.copy( + isMigrating = true, + migrationProgress = 0, + migrationTotal = totalFiles + ) + } + } + + // Run migration with progress updates + metadataMigrationManager.executeMigration { current, total -> + withContext(Dispatchers.Main) { + _uiState.update { + it.copy( + migrationProgress = current, + migrationTotal = total + ) + } + } + } + + withContext(Dispatchers.Main) { + _uiState.update { + it.copy(isMigrating = false) + } + } + } + fun invalidateSession() = invalidateSessionUseCase.invalidateSession() } @@ -186,5 +227,8 @@ data class PinVerificationUiState( val failedAttempts: Int = 0, val isBackoffActive: Boolean = false, val remainingBackoffSeconds: Int = 0, - val isAlphanumericPinEnabled: Boolean = false + val isAlphanumericPinEnabled: Boolean = false, + val isMigrating: Boolean = false, + val migrationProgress: Int = 0, + val migrationTotal: Int = 0 ) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraState.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraState.kt index a6b8ec7..82932e0 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraState.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraState.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.unit.IntSize import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.coroutineScope +import com.darkrockstudios.app.securecamera.camera.SecureImageRepository.Companion.generateRandomFilename import com.darkrockstudios.app.securecamera.encryption.VideoEncryptionService import com.darkrockstudios.app.securecamera.obfuscation.FacialDetection import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource @@ -258,6 +259,7 @@ class CameraState internal constructor( * 1. CameraX writes to a temporary .mp4 file * 2. On recording complete, the temp file is encrypted to .secv format * 3. The temp file is securely deleted after encryption + * 4. Metadata entry is added to the encrypted sidecar */ @SuppressLint("MissingPermission") fun startRecording(context: Context): File? { @@ -277,15 +279,17 @@ class CameraState internal constructor( videosDir.mkdirs() } - val timestamp = java.text.SimpleDateFormat( - "yyyyMMdd_HHmmss", - java.util.Locale.US - ).format(System.currentTimeMillis()) + val recordingTimestamp = System.currentTimeMillis() + + val randomFilename = generateRandomFilename( + MediaType.VIDEO, + SecvFileFormat.FILE_EXTENSION + ) // Temp file for CameraX recording (unencrypted) - val tempFile = File(videosDir, "temp_$timestamp.mp4") + val tempFile = File(videosDir, "temp_${randomFilename.removeSuffix(".${SecvFileFormat.FILE_EXTENSION}")}.mp4") // Final encrypted output file - val outputFile = File(videosDir, "video_$timestamp.${SecvFileFormat.FILE_EXTENSION}") + val outputFile = File(videosDir, randomFilename) pendingTempFile = tempFile pendingOutputFile = outputFile @@ -319,8 +323,8 @@ class CameraState internal constructor( pendingOutputFile = null } else { Timber.i("Recording complete, enqueueing encryption...") - // Enqueue encryption via ForegroundService - enqueueVideoEncryption(context, tempFile, outputFile) + // Enqueue encryption via ForegroundService (metadata will be added after encryption) + enqueueVideoEncryption(context, tempFile, outputFile, recordingTimestamp) } } } @@ -333,9 +337,9 @@ class CameraState internal constructor( * Enqueues the recorded video for encryption via the ForegroundService. * The service handles encryption in the background with progress notifications. */ - private fun enqueueVideoEncryption(context: Context, tempFile: File, outputFile: File) { + private fun enqueueVideoEncryption(context: Context, tempFile: File, outputFile: File, timestamp: Long) { Timber.i("Enqueueing video encryption: ${tempFile.name} -> ${outputFile.name}") - VideoEncryptionService.enqueueEncryption(context, tempFile, outputFile) + VideoEncryptionService.enqueueEncryption(context, tempFile, outputFile, timestamp) pendingTempFile = null pendingOutputFile = null } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/PhotoDef.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/PhotoDef.kt index fcd49a9..10be1ed 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/PhotoDef.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/PhotoDef.kt @@ -1,15 +1,13 @@ package com.darkrockstudios.app.securecamera.camera -import timber.log.Timber import java.io.File -import java.text.ParseException -import java.text.SimpleDateFormat import java.util.* data class PhotoDef( val photoName: String, val photoFormat: String, val photoFile: File, + val metadataTimestamp: Long? = null, ) : MediaItem { override val mediaName: String get() = photoName @@ -17,13 +15,6 @@ data class PhotoDef( override val mediaType: MediaType get() = MediaType.PHOTO override fun dateTaken(): Date { - try { - val dateString = photoName.removePrefix("photo_").removeSuffix(".jpg") - val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss_SS", Locale.US) - return dateFormat.parse(dateString) ?: Date() - } catch (e: ParseException) { - Timber.w(e, "Failed to parse photo name to date") - return Date() - } + return metadataTimestamp?.let { Date(it) } ?: Date() } } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt index eee6adf..9365850 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt @@ -12,26 +12,30 @@ import com.ashampoo.kim.common.convertToPhotoMetadata import com.ashampoo.kim.model.GpsCoordinates import com.ashampoo.kim.model.MetadataUpdate import com.ashampoo.kim.model.TiffOrientation +import com.darkrockstudios.app.securecamera.metadata.MediaMetadataEntry +import com.darkrockstudios.app.securecamera.metadata.MetadataManager +import com.darkrockstudios.app.securecamera.security.FileTimestampObfuscator import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme import com.darkrockstudios.app.securecamera.security.streaming.SecvFileFormat import com.darkrockstudios.app.securecamera.security.streaming.StreamingDecryptor import com.darkrockstudios.app.securecamera.security.streaming.StreamingEncryptionScheme import com.darkrockstudios.app.securecamera.security.streaming.VideoEncryptionHelper import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream -import java.text.SimpleDateFormat import java.util.* -import kotlin.time.toJavaInstant class SecureImageRepository( private val appContext: Context, internal val thumbnailCache: ThumbnailCache, private val encryptionScheme: EncryptionScheme, + private val metadataManager: MetadataManager, + private val fileTimestampObfuscator: FileTimestampObfuscator, ) { fun getGalleryDirectory(): File = File(appContext.filesDir, PHOTOS_DIR) @@ -39,14 +43,19 @@ class SecureImageRepository( return File(appContext.filesDir, DECOYS_DIR) } - fun evictKey() = encryptionScheme.evictKey() + fun evictKey() { + encryptionScheme.evictKey() + metadataManager.evict() + } /** * Resets all security-related data when a security failure occurs. * Deletes all images, videos, temp files, thumbnails, and evicts all in-memory data. */ fun securityFailureReset() { - deleteAllImages() + runBlocking { + deleteAllImages() + } deleteAllVideos() clearAllThumbnails() evictKey() @@ -189,8 +198,8 @@ class SecureImageRepository( dir.mkdirs() } - val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss_SS", Locale.US) - val finalImageName: String = "photo_" + dateFormat.format(Date.from(image.timestamp.toJavaInstant())) + ".jpg" + val finalImageName = generateRandomFilename(MediaType.PHOTO, "jpg") + val timestamp = image.timestamp.toEpochMilliseconds() val photoFile = File(dir, finalImageName) val tempFile = File(dir, "$finalImageName.tmp") @@ -204,6 +213,15 @@ class SecureImageRepository( val updatedBytes = applyImageMetadata(jpgBytes, latLng, applyRotation, image.rotationDegrees) encryptAndSaveImage(updatedBytes, tempFile, photoFile) + fileTimestampObfuscator.obfuscate(photoFile) + + val entry = MediaMetadataEntry( + filename = finalImageName, + originalTimestamp = timestamp, + mediaType = MediaType.PHOTO, + fileSize = photoFile.length() + ) + metadataManager.addEntry(entry) return photoFile } @@ -236,16 +254,28 @@ class SecureImageRepository( val updatedBytes = processImageWithMetadata(bitmap, jpgBytes, quality) val dir = getGalleryDirectory() - val newImageName = generateCopyName(dir, photoDef.photoName) + val newImageName = generateRandomFilename(MediaType.PHOTO, "jpg") val newPhotoFile = File(dir, newImageName) val tempFile = File(dir, "$newImageName.tmp") encryptAndSaveImage(updatedBytes, tempFile, newPhotoFile) + val timestamp = photoDef.metadataTimestamp ?: photoDef.dateTaken().time + + val entry = MediaMetadataEntry( + filename = newImageName, + originalTimestamp = timestamp, + mediaType = MediaType.PHOTO, + originalFilename = photoDef.photoName, + fileSize = newPhotoFile.length() + ) + metadataManager.addEntry(entry) + val newPhotoDef = PhotoDef( photoName = newImageName, photoFormat = "jpg", - photoFile = newPhotoFile + photoFile = newPhotoFile, + metadataTimestamp = timestamp ) return newPhotoDef @@ -313,6 +343,7 @@ class SecureImageRepository( plain = thumbnailBytes, targetFile = thumbFile, ) + fileTimestampObfuscator.obfuscate(thumbFile) thumbnailBitmap } @@ -335,10 +366,12 @@ class SecureImageRepository( ?.map { file -> val name = file.name val format = name.substringAfterLast('.', "jpg") + val metadataEntry = metadataManager.getEntry(name) PhotoDef( photoName = name, photoFormat = format, - photoFile = file + photoFile = file, + metadataTimestamp = metadataEntry?.originalTimestamp ) } ?: emptyList() } @@ -369,10 +402,12 @@ class SecureImageRepository( ?.map { file -> val name = file.name val format = name.substringAfterLast('.', "mp4") + val metadataEntry = metadataManager.getEntry(name) VideoDef( videoName = name, videoFormat = format, - videoFile = file + videoFile = file, + metadataTimestamp = metadataEntry?.originalTimestamp ) } ?: emptyList() } @@ -398,10 +433,12 @@ class SecureImageRepository( } val format = videoName.substringAfterLast('.', SecvFileFormat.FILE_EXTENSION) + val metadataEntry = metadataManager.getEntry(videoName) return VideoDef( videoName = videoName, videoFormat = format, - videoFile = videoFile + videoFile = videoFile, + metadataTimestamp = metadataEntry?.originalTimestamp ) } @@ -445,6 +482,7 @@ class SecureImageRepository( plain = thumbnailBytes, targetFile = thumbFile, ) + fileTimestampObfuscator.obfuscate(thumbFile) scaledBitmap } @@ -554,10 +592,12 @@ class SecureImageRepository( return File(dir, video.videoName + ".thumb") } - fun deleteVideo(video: VideoDef): Boolean { + suspend fun deleteVideo(video: VideoDef): Boolean { thumbnailCache.evictThumbnail(video) getVideoThumbnail(video).delete() + metadataManager.removeEntry(video.videoName) + return if (video.videoFile.exists()) { video.videoFile.delete() } else { @@ -565,7 +605,7 @@ class SecureImageRepository( } } - fun deleteVideos(videos: List): Boolean { + suspend fun deleteVideos(videos: List): Boolean { return videos.map { deleteVideo(it) }.all { it } } @@ -604,7 +644,7 @@ class SecureImageRepository( /** * Deletes a media item (photo or video) based on its type. */ - fun deleteMediaItem(mediaItem: MediaItem): Boolean { + suspend fun deleteMediaItem(mediaItem: MediaItem): Boolean { return when (mediaItem) { is PhotoDef -> deleteImage(mediaItem) is VideoDef -> deleteVideo(mediaItem) @@ -614,7 +654,7 @@ class SecureImageRepository( /** * Deletes multiple media items (photos and videos). */ - fun deleteMediaItems(items: List): Boolean { + suspend fun deleteMediaItems(items: List): Boolean { return items.map { deleteMediaItem(it) }.all { it } } @@ -635,12 +675,14 @@ class SecureImageRepository( return getPhotoByName(mediaName) ?: getVideoByName(mediaName) } - fun deleteImage(photoDef: PhotoDef, deleteDecoy: Boolean = true): Boolean { + suspend fun deleteImage(photoDef: PhotoDef, deleteDecoy: Boolean = true): Boolean { thumbnailCache.evictThumbnail(photoDef) if (deleteDecoy && isDecoyPhoto(photoDef)) { getDecoyFile(photoDef).delete() } + metadataManager.removeEntry(photoDef.photoName) + return if (photoDef.photoFile.exists()) { getThumbnail(photoDef).delete() photoDef.photoFile.delete() @@ -649,11 +691,11 @@ class SecureImageRepository( } } - fun deleteImages(photos: List, deleteDecoy: Boolean = true): Boolean { + suspend fun deleteImages(photos: List, deleteDecoy: Boolean = true): Boolean { return photos.map { deleteImage(it, deleteDecoy) }.all { it } } - fun deleteAllImages(deleteDecoy: Boolean = true) { + suspend fun deleteAllImages(deleteDecoy: Boolean = true) { val photos = getPhotos() deleteImages(photos, deleteDecoy) } @@ -687,10 +729,12 @@ class SecureImageRepository( } val format = photoName.substringAfterLast('.', "jpg") + val metadataEntry = metadataManager.getEntry(photoName) return PhotoDef( photoName = photoName, photoFormat = format, - photoFile = photoFile + photoFile = photoFile, + metadataTimestamp = metadataEntry?.originalTimestamp ) } @@ -749,6 +793,7 @@ class SecureImageRepository( keyBytes = keyBytes, targetFile = decoyFile ) + fileTimestampObfuscator.obfuscate(decoyFile) true } else { @@ -790,5 +835,19 @@ class SecureImageRepository( } return candidate } + + /** + * Generates a random UUID-based filename. + * Photos: img_{uuid}.{ext} + * Videos: vid_{uuid}.{ext} + */ + fun generateRandomFilename(type: MediaType, extension: String): String { + val uuid = UUID.randomUUID().toString().replace("-", "") + val prefix = when (type) { + MediaType.PHOTO -> "img" + MediaType.VIDEO -> "vid" + } + return "${prefix}_${uuid}.$extension" + } } } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/VideoDef.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/VideoDef.kt index e104ab0..464b3b7 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/VideoDef.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/VideoDef.kt @@ -1,16 +1,14 @@ package com.darkrockstudios.app.securecamera.camera import com.darkrockstudios.app.securecamera.security.streaming.SecvFileFormat -import timber.log.Timber import java.io.File -import java.text.ParseException -import java.text.SimpleDateFormat import java.util.* data class VideoDef( val videoName: String, val videoFormat: String, val videoFile: File, + val metadataTimestamp: Long? = null, ) : MediaItem { override val mediaName: String get() = videoName @@ -24,14 +22,6 @@ data class VideoDef( get() = videoFormat == SecvFileFormat.FILE_EXTENSION override fun dateTaken(): Date { - try { - // Video filename format: video_yyyyMMdd_HHmmss.mp4 or video_yyyyMMdd_HHmmss.secv - val dateString = videoName.removePrefix("video_").removeSuffix(".$videoFormat") - val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) - return dateFormat.parse(dateString) ?: Date() - } catch (e: ParseException) { - Timber.w(e, "Failed to parse video name to date") - return Date() - } + return metadataTimestamp?.let { Date(it) } ?: Date() } } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/encryption/VideoEncryptionJob.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/encryption/VideoEncryptionJob.kt index 818f64a..6c301e1 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/encryption/VideoEncryptionJob.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/encryption/VideoEncryptionJob.kt @@ -10,6 +10,7 @@ data class VideoEncryptionJob( val tempFile: File, val outputFile: File, val createdAt: Long, + val recordingTimestamp: Long, val status: JobStatus = JobStatus.Pending ) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/encryption/VideoEncryptionService.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/encryption/VideoEncryptionService.kt index 0a3bc3a..44b9020 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/encryption/VideoEncryptionService.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/encryption/VideoEncryptionService.kt @@ -10,6 +10,11 @@ import android.os.IBinder import androidx.core.app.NotificationCompat import com.darkrockstudios.app.securecamera.MainActivity import com.darkrockstudios.app.securecamera.R +import com.darkrockstudios.app.securecamera.camera.MediaType +import com.darkrockstudios.app.securecamera.camera.SecureImageRepository.Companion.generateRandomFilename +import com.darkrockstudios.app.securecamera.metadata.MediaMetadataEntry +import com.darkrockstudios.app.securecamera.metadata.MetadataManager +import com.darkrockstudios.app.securecamera.security.FileTimestampObfuscator import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme import com.darkrockstudios.app.securecamera.security.streaming.SecvFileFormat import com.darkrockstudios.app.securecamera.security.streaming.VideoEncryptionHelper @@ -32,6 +37,8 @@ import java.util.concurrent.atomic.AtomicBoolean class VideoEncryptionService : Service() { private val encryptionScheme: EncryptionScheme by inject() + private val metadataManager: MetadataManager by inject() + private val fileTimestampObfuscator: FileTimestampObfuscator by inject() private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var encryptionJob: Job? = null @@ -53,6 +60,7 @@ class VideoEncryptionService : Service() { private const val EXTRA_TEMP_FILE = "extra_temp_file" private const val EXTRA_OUTPUT_FILE = "extra_output_file" private const val EXTRA_JOB_ID = "extra_job_id" + private const val EXTRA_TIMESTAMP = "extra_timestamp" /** * Observable state of all encryption jobs. @@ -89,8 +97,14 @@ class VideoEncryptionService : Service() { /** * Enqueue a video file for encryption. + * @param timestamp The timestamp when the video was recorded, used for metadata */ - fun enqueueEncryption(context: Context, tempFile: File, outputFile: File) { + fun enqueueEncryption( + context: Context, + tempFile: File, + outputFile: File, + timestamp: Long = System.currentTimeMillis() + ) { val jobId = UUID.randomUUID().toString() // Add to state immediately as "queued" (null progress) updateProgress(outputFile.name, null) @@ -99,6 +113,7 @@ class VideoEncryptionService : Service() { putExtra(EXTRA_TEMP_FILE, tempFile.absolutePath) putExtra(EXTRA_OUTPUT_FILE, outputFile.absolutePath) putExtra(EXTRA_JOB_ID, jobId) + putExtra(EXTRA_TIMESTAMP, timestamp) } context.startService(intent) } @@ -161,14 +176,13 @@ class VideoEncryptionService : Service() { Timber.w("Found ${tempFiles.size} stranded temp file(s), recovering...") - // Get set of files currently being processed val currentlyProcessing = _encryptionState.value.keys tempFiles.forEach { tempFile -> - // Derive the expected output file name - val outputName = tempFile.name - .replace("temp_", "video_") - .replace(".mp4", ".${SecvFileFormat.FILE_EXTENSION}") + val outputName = generateRandomFilename( + MediaType.VIDEO, + SecvFileFormat.FILE_EXTENSION + ) val outputFile = File(videosDir, outputName) // Skip if already being processed @@ -177,14 +191,11 @@ class VideoEncryptionService : Service() { return@forEach } - // Delete any existing partial output (shouldn't exist if .encrypting is used, but safety check) - if (outputFile.exists()) { - Timber.w("Deleting existing partial output: ${outputFile.name}") - outputFile.delete() - } + // Use file's last modified time as the recording timestamp + val timestamp = tempFile.lastModified() Timber.i("Recovering stranded video: ${tempFile.name} -> ${outputFile.name}") - enqueueEncryption(context, tempFile, outputFile) + enqueueEncryption(context, tempFile, outputFile, timestamp) } } } @@ -199,7 +210,7 @@ class VideoEncryptionService : Service() { private fun initializeEncryptionHelper() { val streamingScheme = encryptionScheme.getStreamingCapability() if (streamingScheme != null) { - videoEncryptionHelper = VideoEncryptionHelper(streamingScheme) + videoEncryptionHelper = VideoEncryptionHelper(streamingScheme, fileTimestampObfuscator) } else { Timber.e("Streaming encryption not available") } @@ -213,13 +224,15 @@ class VideoEncryptionService : Service() { val tempFilePath = intent.getStringExtra(EXTRA_TEMP_FILE) val outputFilePath = intent.getStringExtra(EXTRA_OUTPUT_FILE) val jobId = intent.getStringExtra(EXTRA_JOB_ID) ?: UUID.randomUUID().toString() + val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, System.currentTimeMillis()) if (tempFilePath != null && outputFilePath != null) { val job = VideoEncryptionJob( jobId = jobId, tempFile = File(tempFilePath), outputFile = File(outputFilePath), - createdAt = System.currentTimeMillis() + createdAt = System.currentTimeMillis(), + recordingTimestamp = timestamp ) enqueueJob(job) } @@ -365,6 +378,21 @@ class VideoEncryptionService : Service() { if (job.tempFile.exists()) { job.tempFile.delete() } + + serviceScope.launch { + try { + val entry = MediaMetadataEntry( + filename = job.outputFile.name, + originalTimestamp = job.recordingTimestamp, + mediaType = MediaType.VIDEO, + fileSize = job.outputFile.length() + ) + metadataManager.addEntry(entry) + Timber.d("Added metadata entry for video: ${job.outputFile.name}") + } catch (e: Exception) { + Timber.e(e, "Failed to add metadata entry for video: ${job.outputFile.name}") + } + } } private fun handleJobCancelled(job: VideoEncryptionJob) { diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryViewModel.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryViewModel.kt index 4316657..f95a5f4 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryViewModel.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryViewModel.kt @@ -88,10 +88,14 @@ class GalleryViewModel( } fun deleteSelectedMedia() { - val mediaItems = uiState.value.selectedMedia.mapNotNull { imageManager.getMediaItemByName(it) } - imageManager.deleteMediaItems(mediaItems) + val selectedNames = uiState.value.selectedMedia + val mediaItems = selectedNames.mapNotNull { imageManager.getMediaItemByName(it) } - val updatedMedia = uiState.value.mediaItems.filter { it.mediaName !in uiState.value.selectedMedia } + viewModelScope.launch { + imageManager.deleteMediaItems(mediaItems) + } + + val updatedMedia = uiState.value.mediaItems.filter { it.mediaName !in selectedNames } _uiState.update { it.copy( mediaItems = updatedMedia, diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntry.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntry.kt new file mode 100644 index 0000000..dbc9c0b --- /dev/null +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntry.kt @@ -0,0 +1,167 @@ +package com.darkrockstudios.app.securecamera.metadata + +import com.darkrockstudios.app.securecamera.camera.MediaType +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Represents a single media metadata entry stored in the SECM sidecar file. + * + * @property filename Current filename of the media file (e.g., "img_550e8400e29b41d4a716446655440000.jpg") + * @property originalTimestamp Epoch milliseconds when the media was captured, used for sorting + * @property mediaType Type of media (PHOTO or VIDEO) + * @property originalFilename Pre-migration filename for audit trail (nullable) + * @property fileSize Size of the media file in bytes, for integrity checking + */ +data class MediaMetadataEntry( + val filename: String, + val originalTimestamp: Long, + val mediaType: MediaType, + val originalFilename: String? = null, + val fileSize: Long = 0 +) { + /** + * Serializes this entry to a 140-byte plaintext buffer. + * + * Layout (140 bytes total): + * - Offset 0: Status (1 byte) - always ACTIVE when serializing + * - Offset 1: Filename (48 bytes, null-padded) + * - Offset 49: Timestamp (8 bytes, int64) + * - Offset 57: MediaType (1 byte, 0=PHOTO, 1=VIDEO) + * - Offset 58: OriginalName (48 bytes, null-padded, or all zeros if null) + * - Offset 106: FileSize (8 bytes, int64) + * - Offset 114: Reserved (26 bytes, zero-filled) + */ + fun toBytes(): ByteArray { + val buffer = ByteBuffer.allocate(SecmFileFormat.ENTRY_PLAINTEXT_SIZE) + buffer.order(ByteOrder.LITTLE_ENDIAN) + + // Status (1 byte) - always active when we're writing an entry + buffer.put(SecmFileFormat.STATUS_ACTIVE) + + // Filename (48 bytes, null-padded) + val filenameBytes = filename.toByteArray(Charsets.UTF_8) + val filenameTruncated = filenameBytes.copyOf( + minOf(filenameBytes.size, SecmFileFormat.FILENAME_MAX_LENGTH) + ) + buffer.put(filenameTruncated) + // Pad remaining space with zeros + repeat(SecmFileFormat.FILENAME_MAX_LENGTH - filenameTruncated.size) { + buffer.put(0) + } + + // Timestamp (8 bytes) + buffer.putLong(originalTimestamp) + + // MediaType (1 byte) + buffer.put(if (mediaType == MediaType.PHOTO) 0 else 1) + + // OriginalName (48 bytes, null-padded or all zeros) + if (originalFilename != null) { + val originalBytes = originalFilename.toByteArray(Charsets.UTF_8) + val originalTruncated = originalBytes.copyOf( + minOf(originalBytes.size, SecmFileFormat.ORIGINAL_NAME_MAX_LENGTH) + ) + buffer.put(originalTruncated) + repeat(SecmFileFormat.ORIGINAL_NAME_MAX_LENGTH - originalTruncated.size) { + buffer.put(0) + } + } else { + repeat(SecmFileFormat.ORIGINAL_NAME_MAX_LENGTH) { + buffer.put(0) + } + } + + // FileSize (8 bytes) + buffer.putLong(fileSize) + + // Reserved (26 bytes, zero-filled) + repeat(SecmFileFormat.ENTRY_RESERVED_SIZE) { + buffer.put(0) + } + + return buffer.array() + } + + companion object { + /** + * Deserializes a 140-byte plaintext buffer to a MediaMetadataEntry. + * + * @param bytes The 140-byte plaintext buffer + * @return The deserialized entry, or null if status is not ACTIVE + */ + fun fromBytes(bytes: ByteArray): MediaMetadataEntry? { + require(bytes.size == SecmFileFormat.ENTRY_PLAINTEXT_SIZE) { + "Invalid entry size: ${bytes.size}, expected ${SecmFileFormat.ENTRY_PLAINTEXT_SIZE}" + } + + val buffer = ByteBuffer.wrap(bytes) + buffer.order(ByteOrder.LITTLE_ENDIAN) + + // Status (1 byte) + val status = buffer.get() + if (status != SecmFileFormat.STATUS_ACTIVE) { + return null + } + + // Filename (48 bytes, null-terminated) + val filenameBytes = ByteArray(SecmFileFormat.FILENAME_MAX_LENGTH) + buffer.get(filenameBytes) + val filename = filenameBytes.decodeNullTerminatedString() + + // Timestamp (8 bytes) + val timestamp = buffer.getLong() + + // MediaType (1 byte) + val mediaTypeByte = buffer.get() + val mediaType = if (mediaTypeByte == 0.toByte()) MediaType.PHOTO else MediaType.VIDEO + + // OriginalName (48 bytes, null-terminated) + val originalNameBytes = ByteArray(SecmFileFormat.ORIGINAL_NAME_MAX_LENGTH) + buffer.get(originalNameBytes) + val originalFilename = originalNameBytes.decodeNullTerminatedString().takeIf { it.isNotEmpty() } + + // FileSize (8 bytes) + val fileSize = buffer.getLong() + + // Skip reserved bytes (26 bytes) + // buffer.position(buffer.position() + SecmFileFormat.ENTRY_RESERVED_SIZE) + + return MediaMetadataEntry( + filename = filename, + originalTimestamp = timestamp, + mediaType = mediaType, + originalFilename = originalFilename, + fileSize = fileSize + ) + } + + /** + * Creates a "deleted" entry for writing to disk. + * The entry will have STATUS_DELETED and all other fields zeroed. + */ + fun deletedEntryBytes(): ByteArray { + val buffer = ByteBuffer.allocate(SecmFileFormat.ENTRY_PLAINTEXT_SIZE) + buffer.order(ByteOrder.LITTLE_ENDIAN) + buffer.put(SecmFileFormat.STATUS_DELETED) + // Rest is already zero-filled + return buffer.array() + } + + /** + * Extracts just the status byte from an entry's plaintext. + */ + fun getStatus(bytes: ByteArray): Byte { + return bytes[SecmFileFormat.ENTRY_OFFSET_STATUS] + } + } +} + +/** + * Decodes a null-terminated UTF-8 string from a byte array. + */ +private fun ByteArray.decodeNullTerminatedString(): String { + val nullIndex = indexOf(0) + val length = if (nullIndex >= 0) nullIndex else size + return String(this, 0, length, Charsets.UTF_8) +} diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt new file mode 100644 index 0000000..fc77d09 --- /dev/null +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt @@ -0,0 +1,460 @@ +package com.darkrockstudios.app.securecamera.metadata + +import android.content.Context +import com.darkrockstudios.app.securecamera.camera.MediaType +import com.darkrockstudios.app.securecamera.security.FileTimestampObfuscator +import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.SecureRandom + +/** + * Core manager for encrypted media metadata with write-through persistence. + * + * Responsibilities: + * - Load: Read header, decrypt all active entries into memory map + * - Cache: Map> (filename -> slot + entry) + * - Write-through: Every mutation immediately writes to disk + * - Evict: Clear memory cache, zero-fill sensitive data + * - Thread safety: Mutex for concurrent access + */ +class MetadataManager( + private val appContext: Context, + private val encryptionScheme: EncryptionScheme, + private val fileTimestampObfuscator: FileTimestampObfuscator, +) { + private val mutex = Mutex() + + private var cachedEntries: MutableMap>? = null + + // Reusable deleted slots + private var freeSlots: MutableList = mutableListOf() + + private var indexFile: RandomAccessFile? = null + + private var photoCount: Int = 0 + private var videoCount: Int = 0 + private var capacity: Int = 0 + + private val secureRandom = SecureRandom() + + fun getMetadataDirectory(): File { + val dir = File(appContext.filesDir, SecmFileFormat.METADATA_DIR) + if (!dir.exists()) { + dir.mkdirs() + } + return dir + } + + private fun getIndexFile(): File { + return File(getMetadataDirectory(), SecmFileFormat.MEDIA_INDEX_FILENAME) + } + + /** + * Loads the entire index into memory. Called once on unlock. + * If the index doesn't exist, creates a new empty one. + */ + suspend fun loadIndex() { + mutex.withLock { + // Already loaded + if (cachedEntries != null) return@withLock + + val file = getIndexFile() + + if (!file.exists()) { + createEmptyIndex(file) + } + + withContext(Dispatchers.IO) { + indexFile = RandomAccessFile(file, "rw") + readIndexFromDisk() + } + } + } + + fun isLoaded(): Boolean = cachedEntries != null + + /** + * Creates a new empty SECM index file. + */ + private suspend fun createEmptyIndex(file: File) { + withContext(Dispatchers.IO) { + file.parentFile?.mkdirs() + + RandomAccessFile(file, "rw").use { raf -> + val header = ByteBuffer.allocate(SecmFileFormat.HEADER_SIZE) + header.order(ByteOrder.LITTLE_ENDIAN) + + // Magic (4 bytes) + header.put(SecmFileFormat.MAGIC.toByteArray(Charsets.US_ASCII)) + // Version (2 bytes) + header.putShort(SecmFileFormat.VERSION) + // Photo count (4 bytes) + header.putInt(0) + // Video count (4 bytes) + header.putInt(0) + // Capacity (4 bytes) + header.putInt(0) + // Entry size (2 bytes) + header.putShort(SecmFileFormat.ENTRY_BLOCK_SIZE.toShort()) + // Reserved (12 bytes) + repeat(SecmFileFormat.HEADER_RESERVED_SIZE) { + header.put(0) + } + + raf.write(header.array()) + } + } + } + + /** + * Reads the entire index from disk into memory. + */ + private suspend fun readIndexFromDisk() = withContext(Dispatchers.IO) { + val raf = indexFile ?: return@withContext + + // Read header + raf.seek(0) + val headerBytes = ByteArray(SecmFileFormat.HEADER_SIZE) + raf.readFully(headerBytes) + + val header = ByteBuffer.wrap(headerBytes) + header.order(ByteOrder.LITTLE_ENDIAN) + + // Validate magic + val magic = ByteArray(4) + header.get(magic) + val magicStr = String(magic, Charsets.US_ASCII) + if (magicStr != SecmFileFormat.MAGIC) { + error("Invalid SECM file: magic mismatch ($magicStr)") + } + + // Read header fields + val version = header.getShort() + if (version != SecmFileFormat.VERSION) { + error("Unsupported SECM version: $version") + } + + photoCount = header.getInt() + videoCount = header.getInt() + capacity = header.getInt() + + // Initialize cache + cachedEntries = mutableMapOf() + freeSlots = mutableListOf() + + // Read all entries + val keyBytes = encryptionScheme.getDerivedKey() + + for (slot in 0..capacity) { + val offset = SecmFileFormat.entryOffset(slot) + raf.seek(offset) + + val encryptedBlock = ByteArray(SecmFileFormat.ENTRY_BLOCK_SIZE) + raf.readFully(encryptedBlock) + + try { + val plaintext = decryptEntry(encryptedBlock, keyBytes) + val status = MediaMetadataEntry.getStatus(plaintext) + + when (status) { + SecmFileFormat.STATUS_ACTIVE -> { + val entry = MediaMetadataEntry.fromBytes(plaintext) + if (entry != null) { + cachedEntries!![entry.filename] = slot to entry + } + } + + SecmFileFormat.STATUS_DELETED, SecmFileFormat.STATUS_EMPTY -> { + freeSlots.add(slot) + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to decrypt entry at slot $slot") + // Treat corrupted entries as free slots + freeSlots.add(slot) + } + } + + Timber.d("Loaded metadata index: ${cachedEntries!!.size} entries, ${freeSlots.size} free slots") + } + + /** + * Adds a new entry to the index with write-through persistence. + */ + suspend fun addEntry(entry: MediaMetadataEntry) { + mutex.withLock { + ensureLoaded() + + // Check for duplicate filename + if (cachedEntries!!.containsKey(entry.filename)) { + Timber.w("Entry already exists for filename: ${entry.filename}") + return@withLock + } + + val slot = if (freeSlots.isNotEmpty()) { + freeSlots.removeAt(0) + } else { + allocateNewSlot() + } + + writeEntryToDisk(slot, entry) + cachedEntries!![entry.filename] = slot to entry + + // Update counts + when (entry.mediaType) { + MediaType.PHOTO -> photoCount++ + MediaType.VIDEO -> videoCount++ + } + updateHeaderCounts() + } + } + + /** + * Removes an entry from the index with write-through persistence. + */ + suspend fun removeEntry(filename: String) { + mutex.withLock { + ensureLoaded() + + val (slot, entry) = cachedEntries!!.remove(filename) ?: return@withLock + + markSlotDeleted(slot) + freeSlots.add(slot) + + // Update counts + when (entry.mediaType) { + MediaType.PHOTO -> photoCount-- + MediaType.VIDEO -> videoCount-- + } + updateHeaderCounts() + } + } + + /** + * Updates an existing entry (e.g., after file rename). + */ + suspend fun updateEntry(oldFilename: String, newEntry: MediaMetadataEntry) { + mutex.withLock { + ensureLoaded() + + val (slot, oldEntry) = cachedEntries!!.remove(oldFilename) ?: return@withLock + + writeEntryToDisk(slot, newEntry) + cachedEntries!![newEntry.filename] = slot to newEntry + + // Update counts if media type changed (unlikely but handle it) + if (oldEntry.mediaType != newEntry.mediaType) { + when (oldEntry.mediaType) { + MediaType.PHOTO -> photoCount-- + MediaType.VIDEO -> videoCount-- + } + when (newEntry.mediaType) { + MediaType.PHOTO -> photoCount++ + MediaType.VIDEO -> videoCount++ + } + updateHeaderCounts() + } + } + } + + /** + * Gets an entry from the memory cache (no disk I/O). + */ + fun getEntry(filename: String): MediaMetadataEntry? { + return cachedEntries?.get(filename)?.second + } + + /** + * Gets all entries sorted by timestamp (newest first). + */ + fun getAllEntriesSorted(): List { + return cachedEntries?.values + ?.map { it.second } + ?.sortedByDescending { it.originalTimestamp } + ?: emptyList() + } + + /** + * Gets all photo entries sorted by timestamp. + */ + fun getPhotoEntriesSorted(): List { + return getAllEntriesSorted().filter { it.mediaType == MediaType.PHOTO } + } + + /** + * Gets all video entries sorted by timestamp. + */ + fun getVideoEntriesSorted(): List { + return getAllEntriesSorted().filter { it.mediaType == MediaType.VIDEO } + } + + /** + * Returns the current entry counts. + */ + fun getCounts(): Pair = photoCount to videoCount + + /** + * Evicts all sensitive data from memory and closes file handles. + */ + fun evict() { + try { + // Clear cache + cachedEntries?.clear() + cachedEntries = null + freeSlots.clear() + + // Close file handle + try { + indexFile?.close() + } catch (e: Exception) { + Timber.e(e, "Error closing index file") + } + indexFile = null + + // Reset counts + photoCount = 0 + videoCount = 0 + capacity = 0 + + Timber.d("Metadata manager evicted") + } catch (e: Exception) { + Timber.e(e, "Error during eviction") + } + } + + /** + * Allocates a new slot at the end of the file. + */ + private suspend fun allocateNewSlot(): Int { + val newSlot = capacity + capacity++ + + // Update header capacity + withContext(Dispatchers.IO) { + indexFile?.let { raf -> + raf.seek(SecmFileFormat.HEADER_OFFSET_CAPACITY.toLong()) + val buffer = ByteBuffer.allocate(4) + buffer.order(ByteOrder.LITTLE_ENDIAN) + buffer.putInt(capacity) + raf.write(buffer.array()) + } + } + + return newSlot + } + + /** + * Writes a single entry to a specific slot on disk. + */ + private suspend fun writeEntryToDisk(slot: Int, entry: MediaMetadataEntry) { + withContext(Dispatchers.IO) { + val raf = indexFile ?: error("Index file not open") + + val plaintext = entry.toBytes() + val keyBytes = encryptionScheme.getDerivedKey() + val encrypted = encryptEntry(plaintext, keyBytes) + + raf.seek(SecmFileFormat.entryOffset(slot)) + raf.write(encrypted) + obfuscateIndexTimestamp() + } + } + + /** + * Marks a slot as deleted on disk. + */ + private suspend fun markSlotDeleted(slot: Int) { + withContext(Dispatchers.IO) { + val raf = indexFile ?: error("Index file not open") + + val plaintext = MediaMetadataEntry.deletedEntryBytes() + val keyBytes = encryptionScheme.getDerivedKey() + val encrypted = encryptEntry(plaintext, keyBytes) + + raf.seek(SecmFileFormat.entryOffset(slot)) + raf.write(encrypted) + obfuscateIndexTimestamp() + } + } + + /** + * Updates the photo and video counts in the header. + */ + private suspend fun updateHeaderCounts() { + withContext(Dispatchers.IO) { + indexFile?.let { raf -> + val buffer = ByteBuffer.allocate(8) + buffer.order(ByteOrder.LITTLE_ENDIAN) + buffer.putInt(photoCount) + buffer.putInt(videoCount) + + raf.seek(SecmFileFormat.HEADER_OFFSET_PHOTO_COUNT.toLong()) + raf.write(buffer.array()) + obfuscateIndexTimestamp() + } + } + } + + /** + * Encrypts an entry plaintext with AES-GCM. + * Output: [IV (12 bytes)][Ciphertext (140 bytes)][Auth Tag (16 bytes)] + */ + private suspend fun encryptEntry(plaintext: ByteArray, keyBytes: ByteArray): ByteArray { + // Generate random IV + val iv = ByteArray(SecmFileFormat.IV_SIZE) + secureRandom.nextBytes(iv) + + // Encrypt with AES-GCM + val ciphertext = encryptionScheme.encrypt(plaintext, keyBytes) + + // The encrypt method returns [IV + ciphertext + tag], but we want explicit control + // Actually, looking at SoftwareEncryptionScheme, it returns the full encrypted blob + // We need to prepend our IV. Let's check the actual behavior. + + // Actually the cryptography library handles IV internally, so we get back + // [IV][ciphertext][tag] already. We just need to ensure consistent size. + return ciphertext + } + + /** + * Decrypts an entry block back to plaintext. + */ + private suspend fun decryptEntry(encryptedBlock: ByteArray, keyBytes: ByteArray): ByteArray { + // The encrypt/decrypt functions handle IV extraction internally + return withContext(Dispatchers.IO) { + // Use a direct approach since we need to decrypt with the raw key + val aesGcm = dev.whyoleg.cryptography.CryptographyProvider.Default.get( + dev.whyoleg.cryptography.algorithms.AES.GCM + ) + val key = aesGcm.keyDecoder().decodeFromByteArray( + dev.whyoleg.cryptography.algorithms.AES.Key.Format.RAW, + keyBytes + ) + key.cipher().decrypt(encryptedBlock) + } + } + + /** + * Ensures the index is loaded before operations. + */ + private fun ensureLoaded() { + if (cachedEntries == null) { + error("Metadata index not loaded. Call loadIndex() first.") + } + } + + /** + * Obfuscates the index file timestamp to prevent metadata leakage. + */ + private fun obfuscateIndexTimestamp() { + fileTimestampObfuscator.obfuscate(getIndexFile()) + } +} diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataMigrationManager.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataMigrationManager.kt new file mode 100644 index 0000000..11d2fdb --- /dev/null +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataMigrationManager.kt @@ -0,0 +1,268 @@ +package com.darkrockstudios.app.securecamera.metadata + +import android.content.Context +import com.darkrockstudios.app.securecamera.camera.MediaType +import com.darkrockstudios.app.securecamera.camera.SecureImageRepository +import com.darkrockstudios.app.securecamera.camera.SecureImageRepository.Companion.generateRandomFilename +import com.darkrockstudios.app.securecamera.security.FileTimestampObfuscator +import com.darkrockstudios.app.securecamera.security.streaming.SecvFileFormat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.* + +/** + * Handles one-time migration from timestamp-based filenames to random UUID filenames. + * + * Migration process: + * 1. Create migration marker file + * 2. For each legacy-named file: + * a. Parse timestamp from filename + * b. Generate new random filename + * c. Add entry to sidecar + * d. Rename file + * e. Rename thumbnail if exists + * 3. Delete migration marker + * + * Crash recovery: If marker exists on startup, scan for remaining legacy files and continue. + */ +class MetadataMigrationManager( + private val appContext: Context, + private val metadataManager: MetadataManager, + private val imageRepository: SecureImageRepository, + private val fileTimestampObfuscator: FileTimestampObfuscator, +) { + private val photoDateFormat = SimpleDateFormat("yyyyMMdd_HHmmss_SS", Locale.US) + private val videoDateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) + + /** + * Returns the migration marker file. + */ + private fun getMigrationMarker(): File { + return File(metadataManager.getMetadataDirectory(), SecmFileFormat.MIGRATION_MARKER_FILENAME) + } + + /** + * Checks if migration is needed. + * Returns true if: + * - Migration marker exists (crashed during previous migration) + * - Any legacy-named photos exist (photo_*.jpg) + * - Any legacy-named videos exist (video_*.secv or video_*.mp4) + */ + suspend fun needsMigration(): Boolean { + if (getMigrationMarker().exists()) { + Timber.d("Migration marker exists - migration incomplete") + return true + } + + val photosDir = imageRepository.getGalleryDirectory() + val videosDir = imageRepository.getVideosDirectory() + + val hasLegacyPhotos = photosDir.listFiles()?.any { file -> + file.isFile && file.name.startsWith("photo_") && file.name.endsWith(".jpg") + } ?: false + + val hasLegacyVideos = videosDir.listFiles()?.any { file -> + file.isFile && file.name.startsWith("video_") && + (file.name.endsWith(".${SecvFileFormat.FILE_EXTENSION}") || file.name.endsWith(".mp4")) + } ?: false + + return hasLegacyPhotos || hasLegacyVideos + } + + /** + * Counts the total number of files that need migration. + */ + suspend fun countFilesToMigrate(): Int { + val photosDir = imageRepository.getGalleryDirectory() + val videosDir = imageRepository.getVideosDirectory() + + val legacyPhotos = photosDir.listFiles()?.count { file -> + file.isFile && file.name.startsWith("photo_") && file.name.endsWith(".jpg") + } ?: 0 + + val legacyVideos = videosDir.listFiles()?.count { file -> + file.isFile && file.name.startsWith("video_") && + (file.name.endsWith(".${SecvFileFormat.FILE_EXTENSION}") || file.name.endsWith(".mp4")) + } ?: 0 + + return legacyPhotos + legacyVideos + } + + /** + * Executes the migration process with progress reporting. + * + * @param onProgress Callback for progress updates (current, total) + */ + suspend fun executeMigration(onProgress: suspend (current: Int, total: Int) -> Unit) { + withContext(Dispatchers.IO) { + Timber.i("Starting metadata migration") + + // Create marker file + val marker = getMigrationMarker() + marker.parentFile?.mkdirs() + marker.createNewFile() + + // Ensure metadata index is loaded + metadataManager.loadIndex() + + val photosDir = imageRepository.getGalleryDirectory() + val videosDir = imageRepository.getVideosDirectory() + val thumbnailsDir = File(appContext.cacheDir, SecureImageRepository.THUMBNAILS_DIR) + + // Collect all files to migrate + val legacyPhotos = photosDir.listFiles()?.filter { file -> + file.isFile && file.name.startsWith("photo_") && file.name.endsWith(".jpg") + } ?: emptyList() + + val legacyVideos = videosDir.listFiles()?.filter { file -> + file.isFile && file.name.startsWith("video_") && + (file.name.endsWith(".${SecvFileFormat.FILE_EXTENSION}") || file.name.endsWith(".mp4")) + } ?: emptyList() + + val total = legacyPhotos.size + legacyVideos.size + var current = 0 + + Timber.i("Migrating $total files (${legacyPhotos.size} photos, ${legacyVideos.size} videos)") + + // Migrate photos + for (file in legacyPhotos) { + try { + migratePhoto(file, photosDir, thumbnailsDir) + } catch (e: Exception) { + Timber.e(e, "Failed to migrate photo: ${file.name}") + } + current++ + onProgress(current, total) + } + + // Migrate videos + for (file in legacyVideos) { + try { + migrateVideo(file, videosDir, thumbnailsDir) + } catch (e: Exception) { + Timber.e(e, "Failed to migrate video: ${file.name}") + } + current++ + onProgress(current, total) + } + + // Delete marker to indicate completion + marker.delete() + + Timber.i("Migration complete: $current files migrated") + } + } + + /** + * Migrates a single photo file. + */ + private suspend fun migratePhoto(file: File, photosDir: File, thumbnailsDir: File) { + val oldName = file.name + val extension = oldName.substringAfterLast('.', "jpg") + + val timestamp = parsePhotoTimestamp(oldName) + + val newName = generateRandomFilename(MediaType.PHOTO, extension) + val newFile = File(photosDir, newName) + + // Add metadata entry first (atomic write) + val entry = MediaMetadataEntry( + filename = newName, + originalTimestamp = timestamp, + mediaType = MediaType.PHOTO, + originalFilename = oldName, + fileSize = file.length() + ) + metadataManager.addEntry(entry) + + // Rename the file + if (!file.renameTo(newFile)) { + metadataManager.removeEntry(newName) + error("Failed to rename $oldName to $newName") + } + fileTimestampObfuscator.obfuscate(newFile) + + // Rename thumbnail if it exists + val oldThumbnail = File(thumbnailsDir, oldName) + if (oldThumbnail.exists()) { + val newThumbnail = File(thumbnailsDir, newName) + oldThumbnail.renameTo(newThumbnail) + fileTimestampObfuscator.obfuscate(newThumbnail) + } + + Timber.d("Migrated photo: $oldName -> $newName") + } + + /** + * Migrates a single video file. + */ + private suspend fun migrateVideo(file: File, videosDir: File, thumbnailsDir: File) { + val oldName = file.name + val extension = oldName.substringAfterLast('.', SecvFileFormat.FILE_EXTENSION) + + val timestamp = parseVideoTimestamp(oldName) + + val newName = generateRandomFilename(MediaType.VIDEO, extension) + val newFile = File(videosDir, newName) + + // Add metadata entry first (atomic write) + val entry = MediaMetadataEntry( + filename = newName, + originalTimestamp = timestamp, + mediaType = MediaType.VIDEO, + originalFilename = oldName, + fileSize = file.length() + ) + metadataManager.addEntry(entry) + + // Rename the file + if (!file.renameTo(newFile)) { + metadataManager.removeEntry(newName) + error("Failed to rename $oldName to $newName") + } + fileTimestampObfuscator.obfuscate(newFile) + + // Rename thumbnail if it exists + val oldThumbnail = File(thumbnailsDir, "$oldName.thumb") + if (oldThumbnail.exists()) { + val newThumbnail = File(thumbnailsDir, "$newName.thumb") + oldThumbnail.renameTo(newThumbnail) + fileTimestampObfuscator.obfuscate(newThumbnail) + } + + Timber.d("Migrated video: $oldName -> $newName") + } + + /** + * Parses timestamp from a legacy photo filename. + * Format: photo_yyyyMMdd_HHmmss_SS.jpg + */ + private fun parsePhotoTimestamp(filename: String): Long { + return try { + val dateString = filename.removePrefix("photo_").removeSuffix(".jpg") + photoDateFormat.parse(dateString)?.time ?: System.currentTimeMillis() + } catch (e: ParseException) { + Timber.w(e, "Failed to parse photo timestamp from: $filename") + System.currentTimeMillis() + } + } + + /** + * Parses timestamp from a legacy video filename. + * Format: video_yyyyMMdd_HHmmss.secv or video_yyyyMMdd_HHmmss.mp4 + */ + private fun parseVideoTimestamp(filename: String): Long { + return try { + val extension = filename.substringAfterLast('.') + val dateString = filename.removePrefix("video_").removeSuffix(".$extension") + videoDateFormat.parse(dateString)?.time ?: System.currentTimeMillis() + } catch (e: ParseException) { + Timber.w(e, "Failed to parse video timestamp from: $filename") + System.currentTimeMillis() + } + } +} diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/SecmFileFormat.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/SecmFileFormat.kt new file mode 100644 index 0000000..4b77ae0 --- /dev/null +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/SecmFileFormat.kt @@ -0,0 +1,86 @@ +package com.darkrockstudios.app.securecamera.metadata + +/** + * Binary file format constants and utilities for the SECM (Secure Camera Metadata) format. + * + * File Structure: + * [Header - 32 bytes, unencrypted] + * [Entry 0 - 168 bytes, independently encrypted] + * [Entry 1 - 168 bytes, independently encrypted] + * ... + * [Entry N - 168 bytes, independently encrypted] + * + * Each entry is independently encrypted with its own IV, allowing in-place updates. + */ +object SecmFileFormat { + /** Magic bytes identifying SECM format: "SECM" (0x5345434D) */ + const val MAGIC = "SECM" + + /** Current format version */ + const val VERSION: Short = 1 + + /** Size of the unencrypted header in bytes */ + const val HEADER_SIZE = 32 + + /** Size of plaintext entry data before encryption */ + const val ENTRY_PLAINTEXT_SIZE = 140 + + /** Size of each encrypted entry block: 12 (IV) + 140 (ciphertext) + 16 (auth tag) */ + const val ENTRY_BLOCK_SIZE = 168 + + /** IV size for AES-GCM encryption */ + const val IV_SIZE = 12 + + /** Authentication tag size for AES-GCM */ + const val AUTH_TAG_SIZE = 16 + + // Entry status values + /** Slot is empty and available for reuse */ + const val STATUS_EMPTY: Byte = 0 + + /** Slot contains an active entry */ + const val STATUS_ACTIVE: Byte = 1 + + /** Slot was deleted and can be reused */ + const val STATUS_DELETED: Byte = 2 + + // Header field offsets + const val HEADER_OFFSET_MAGIC = 0 + const val HEADER_OFFSET_VERSION = 4 + const val HEADER_OFFSET_PHOTO_COUNT = 6 + const val HEADER_OFFSET_VIDEO_COUNT = 10 + const val HEADER_OFFSET_CAPACITY = 14 + const val HEADER_OFFSET_ENTRY_SIZE = 18 + const val HEADER_OFFSET_RESERVED = 20 + + // Entry field offsets (within plaintext) + const val ENTRY_OFFSET_STATUS = 0 + const val ENTRY_OFFSET_FILENAME = 1 + const val ENTRY_OFFSET_TIMESTAMP = 49 + const val ENTRY_OFFSET_MEDIA_TYPE = 57 + const val ENTRY_OFFSET_ORIGINAL_NAME = 58 + const val ENTRY_OFFSET_FILE_SIZE = 106 + const val ENTRY_OFFSET_RESERVED = 114 + + // Field sizes + const val FILENAME_MAX_LENGTH = 48 + const val ORIGINAL_NAME_MAX_LENGTH = 48 + const val ENTRY_RESERVED_SIZE = 26 + const val HEADER_RESERVED_SIZE = 12 + + /** Directory name for metadata storage */ + const val METADATA_DIR = ".metadata" + + /** Filename for main media index */ + const val MEDIA_INDEX_FILENAME = "media_index.secm" + + /** Marker file for migration in progress */ + const val MIGRATION_MARKER_FILENAME = ".migration_in_progress" + + /** + * Calculates the byte offset for a given entry slot. + * @param slot The zero-based slot index + * @return The byte offset from the beginning of the file + */ + fun entryOffset(slot: Int): Long = HEADER_SIZE + (slot.toLong() * ENTRY_BLOCK_SIZE) +} diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/FileTimestampObfuscator.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/FileTimestampObfuscator.kt new file mode 100644 index 0000000..c3ef9ee --- /dev/null +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/FileTimestampObfuscator.kt @@ -0,0 +1,44 @@ +package com.darkrockstudios.app.securecamera.security + +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributeView +import java.nio.file.attribute.FileTime + + +/** + * Utility class for obfuscating filesystem timestamps on created files. + * This prevents metadata leakage about when photos/videos were actually taken. + * The real timestamps are stored securely in encrypted sidecar metadata. + */ +class FileTimestampObfuscator { + /** + * Sets the file's various timestamps to a fixed date + * @param file The file to obfuscate + * @return true if the timestamp was successfully set, false otherwise + */ + fun obfuscate(file: File): Boolean { + return updateFileMetadata(file, OBFUSCATED_TIMESTAMP) + } + + private fun updateFileMetadata(file: File, creationMillis: Long): Boolean { + return try { + val path: Path = file.toPath() + val view: BasicFileAttributeView = Files.getFileAttributeView(path, BasicFileAttributeView::class.java) + val creationTime = FileTime.fromMillis(creationMillis) + view.setTimes(creationTime, creationTime, creationTime) + true + } catch (e: IOException) { + file.setLastModified(OBFUSCATED_TIMESTAMP) + Timber.e(e, "Failed to update file creation time") + false + } + } + + companion object { + private const val OBFUSCATED_TIMESTAMP = 973382400000L + } +} diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/VideoEncryptionHelper.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/VideoEncryptionHelper.kt index b85a9da..93195dd 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/VideoEncryptionHelper.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/VideoEncryptionHelper.kt @@ -1,5 +1,6 @@ package com.darkrockstudios.app.securecamera.security.streaming +import com.darkrockstudios.app.securecamera.security.FileTimestampObfuscator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -14,7 +15,8 @@ import java.io.RandomAccessFile * Handles the post-recording encryption flow and secure temp file deletion. */ class VideoEncryptionHelper( - private val streamingScheme: StreamingEncryptionScheme + private val streamingScheme: StreamingEncryptionScheme, + private val fileTimestampObfuscator: FileTimestampObfuscator, ) { private val _encryptionProgress = MutableStateFlow(EncryptionProgress.Idle) val encryptionProgress: StateFlow = _encryptionProgress.asStateFlow() @@ -98,6 +100,7 @@ class VideoEncryptionHelper( encryptingFile.delete() return@withContext false } + fileTimestampObfuscator.obfuscate(outputFile) tempFile.delete() 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 3551f38..f697351 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 @@ -143,7 +143,9 @@ class ViewPhotoViewModel( fun deleteCurrentMedia() { val currentMedia = getCurrentMedia() ?: return - imageManager.deleteMediaItem(currentMedia) + viewModelScope.launch { + imageManager.deleteMediaItem(currentMedia) + } _uiState.update { it.copy(mediaDeleted = true) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6819bc1..baf49fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -441,4 +441,9 @@ BE LOST! + + Upgrading Security + Migrating %1$d of %2$d files… + Please wait. Do not close the app. + From ef231fa74db750d8c7520f4481996b945d203bb8 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 31 Jan 2026 18:46:55 -0800 Subject: [PATCH 24/47] Clean up !! --- .../securecamera/metadata/MetadataManager.kt | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt index fc77d09..304be61 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt @@ -147,7 +147,8 @@ class MetadataManager( capacity = header.getInt() // Initialize cache - cachedEntries = mutableMapOf() + val newEntries = mutableMapOf>() + cachedEntries = newEntries freeSlots = mutableListOf() // Read all entries @@ -168,7 +169,7 @@ class MetadataManager( SecmFileFormat.STATUS_ACTIVE -> { val entry = MediaMetadataEntry.fromBytes(plaintext) if (entry != null) { - cachedEntries!![entry.filename] = slot to entry + newEntries[entry.filename] = slot to entry } } @@ -183,7 +184,7 @@ class MetadataManager( } } - Timber.d("Loaded metadata index: ${cachedEntries!!.size} entries, ${freeSlots.size} free slots") + Timber.d("Loaded metadata index: ${newEntries.size} entries, ${freeSlots.size} free slots") } /** @@ -191,10 +192,10 @@ class MetadataManager( */ suspend fun addEntry(entry: MediaMetadataEntry) { mutex.withLock { - ensureLoaded() + val entries = requireLoaded() // Check for duplicate filename - if (cachedEntries!!.containsKey(entry.filename)) { + if (entries.containsKey(entry.filename)) { Timber.w("Entry already exists for filename: ${entry.filename}") return@withLock } @@ -206,7 +207,7 @@ class MetadataManager( } writeEntryToDisk(slot, entry) - cachedEntries!![entry.filename] = slot to entry + entries[entry.filename] = slot to entry // Update counts when (entry.mediaType) { @@ -222,9 +223,9 @@ class MetadataManager( */ suspend fun removeEntry(filename: String) { mutex.withLock { - ensureLoaded() + val entries = requireLoaded() - val (slot, entry) = cachedEntries!!.remove(filename) ?: return@withLock + val (slot, entry) = entries.remove(filename) ?: return@withLock markSlotDeleted(slot) freeSlots.add(slot) @@ -243,12 +244,12 @@ class MetadataManager( */ suspend fun updateEntry(oldFilename: String, newEntry: MediaMetadataEntry) { mutex.withLock { - ensureLoaded() + val entries = requireLoaded() - val (slot, oldEntry) = cachedEntries!!.remove(oldFilename) ?: return@withLock + val (slot, oldEntry) = entries.remove(oldFilename) ?: return@withLock writeEntryToDisk(slot, newEntry) - cachedEntries!![newEntry.filename] = slot to newEntry + entries[newEntry.filename] = slot to newEntry // Update counts if media type changed (unlikely but handle it) if (oldEntry.mediaType != newEntry.mediaType) { @@ -445,10 +446,8 @@ class MetadataManager( /** * Ensures the index is loaded before operations. */ - private fun ensureLoaded() { - if (cachedEntries == null) { - error("Metadata index not loaded. Call loadIndex() first.") - } + private fun requireLoaded(): MutableMap> { + return cachedEntries ?: error("Metadata index not loaded. Call loadIndex() first.") } /** From bb99a2cad4f4888d3650cb73d2e4daedd2f21ee6 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 1 Feb 2026 14:34:58 -0800 Subject: [PATCH 25/47] Decoy photo timestamp obfuscated --- .../app/securecamera/camera/SecureImageRepository.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt index 9365850..a545936 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt @@ -712,7 +712,9 @@ class SecureImageRepository( getDecoyFiles().forEach { file -> val targetFile = File(galleryDir, file.name) - file.renameTo(targetFile) + if (file.renameTo(targetFile)) { + fileTimestampObfuscator.obfuscate(targetFile) + } } getDecoyDirectory().deleteRecursively() } From 2e8196778dc2dbc1a324bb9f0dd2d1c7037c4115 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 1 Feb 2026 17:53:21 -0800 Subject: [PATCH 26/47] Fix bug in metadata manager --- .../app/securecamera/metadata/MetadataManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt index 304be61..b3476e9 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt @@ -154,7 +154,7 @@ class MetadataManager( // Read all entries val keyBytes = encryptionScheme.getDerivedKey() - for (slot in 0..capacity) { + for (slot in 0 until capacity) { val offset = SecmFileFormat.entryOffset(slot) raf.seek(offset) From 7d5c3786e3d298c3f04584f9b8292fb58d30862e Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 1 Feb 2026 18:03:53 -0800 Subject: [PATCH 27/47] Account for orientation of phone Now that we lock camera orientation, we need to explicitly handle rotation --- .../app/securecamera/camera/CameraContent.kt | 2 +- .../app/securecamera/camera/CameraControls.kt | 15 +++++++++------ .../securecamera/camera/SecureImageRepository.kt | 4 +++- 3 files changed, 13 insertions(+), 8 deletions(-) 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 fea3b54..db4b083 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 @@ -144,7 +144,7 @@ internal fun CameraContent( capturePhoto = capturePhoto, navController = navController, paddingValues = paddingValues, - iconRotation = deviceRotation, + cameraRotation = deviceRotation, ) } else { NoCameraPermission(navController, permissionsState) 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 fb99abf..08b0c32 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 @@ -48,7 +48,7 @@ fun CameraControls( capturePhoto: MutableState, navController: NavController, paddingValues: PaddingValues, - iconRotation: Float = 0f, + cameraRotation: Float = 0f, ) { val scope = rememberCoroutineScope() var isFlashOn by rememberSaveable(cameraController.flashMode) { mutableStateOf(cameraController.flashMode == ImageCapture.FLASH_MODE_ON) } @@ -89,6 +89,7 @@ fun CameraControls( location = location, isFlashOn = isFlashOn, context = context, + deviceRotation = cameraRotation.toInt(), ) } finally { activeJobs = @@ -142,14 +143,14 @@ fun CameraControls( modifier = Modifier .align(Alignment.TopCenter) .padding(top = paddingValues.calculateTopPadding().plus(64.dp)), - textRotation = iconRotation, + textRotation = cameraRotation, ) LevelIndicator( modifier = Modifier .align(Alignment.Center) .padding(top = paddingValues.calculateTopPadding().plus(16.dp)), - deviceRotation = iconRotation, + deviceRotation = cameraRotation, ) if (isRecording) { @@ -175,7 +176,7 @@ fun CameraControls( Icon( imageVector = Icons.Filled.MoreVert, contentDescription = stringResource(id = R.string.camera_more_options_content_description), - modifier = Modifier.rotate(iconRotation), + modifier = Modifier.rotate(cameraRotation), ) } } @@ -207,7 +208,7 @@ fun CameraControls( onLensToggle = { cameraController.toggleLens() }, onClose = { isTopControlsVisible = false }, paddingValues = paddingValues, - iconRotation = iconRotation, + iconRotation = cameraRotation, ) } @@ -222,7 +223,7 @@ fun CameraControls( onCapture = { doCapturePhoto() }, onToggleRecording = { doToggleRecording() }, onModeChange = { mode -> cameraController.switchCaptureMode(mode) }, - iconRotation = iconRotation, + iconRotation = cameraRotation, ) } } @@ -234,6 +235,7 @@ private suspend fun handleImageCapture( context: Context, location: Location?, isFlashOn: Boolean, + deviceRotation: Int, ) { val gpsCoordinates = location?.let { GpsCoordinates( @@ -251,6 +253,7 @@ private suspend fun handleImageCapture( val path = imageSaver.saveImage( image = image, applyRotation = true, + deviceRotation = deviceRotation, latLng = gpsCoordinates, ) Timber.i("Image saved at: $path") diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt index a545936..039d94a 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt @@ -190,6 +190,7 @@ class SecureImageRepository( image: CapturedImage, latLng: GpsCoordinates?, applyRotation: Boolean, + deviceRotation: Int = 0, quality: Int = 90, ): File { val dir = getGalleryDirectory() @@ -206,7 +207,8 @@ class SecureImageRepository( var rawSensorBitmap = image.sensorBitmap if (applyRotation) { - rawSensorBitmap = rawSensorBitmap.rotate(image.rotationDegrees) + val effectiveRotation = (image.rotationDegrees - deviceRotation) % 360 + rawSensorBitmap = rawSensorBitmap.rotate(effectiveRotation) } val jpgBytes = compressBitmapToJpeg(rawSensorBitmap, quality) From 8b0797306283975b49a0f9346bbeaffd1c97002b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 1 Feb 2026 23:08:53 -0800 Subject: [PATCH 28/47] Fix video and photo rotation in landscape - Improve metadata manager --- .../auth/PinVerificationViewModel.kt | 4 - .../app/securecamera/camera/CameraControls.kt | 2 +- .../app/securecamera/camera/CameraState.kt | 14 +- .../metadata/MediaMetadataEntry.kt | 12 ++ .../securecamera/metadata/MetadataManager.kt | 61 +++++-- .../securecamera/usecases/CreatePinUseCase.kt | 5 +- .../securecamera/usecases/VerifyPinUseCase.kt | 3 + .../imagemanager/SecureImageRepositoryTest.kt | 14 +- .../metadata/MediaMetadataEntryTest.kt | 163 ++++++++++++++++++ .../metadata/MetadataGrowthTest.kt | 121 +++++++++++++ .../metadata/SecmFileFormatTest.kt | 44 +++++ .../usecases/VerifyPinUseCaseTest.kt | 4 + 12 files changed, 426 insertions(+), 21 deletions(-) create mode 100644 app/src/test/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntryTest.kt create mode 100644 app/src/test/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataGrowthTest.kt create mode 100644 app/src/test/kotlin/com/darkrockstudios/app/securecamera/metadata/SecmFileFormatTest.kt 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 f374fdc..f80b163 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 @@ -7,7 +7,6 @@ import com.darkrockstudios.app.securecamera.BaseViewModel import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.encryption.VideoEncryptionService import com.darkrockstudios.app.securecamera.gallery.vibrateDevice -import com.darkrockstudios.app.securecamera.metadata.MetadataManager import com.darkrockstudios.app.securecamera.metadata.MetadataMigrationManager import com.darkrockstudios.app.securecamera.navigation.Introduction import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource @@ -30,7 +29,6 @@ class PinVerificationViewModel( private val verifyPinUseCase: VerifyPinUseCase, private val pinSizeUseCase: PinSizeUseCase, private val appSettingsDataSource: AppSettingsDataSource, - private val metadataManager: MetadataManager, private val metadataMigrationManager: MetadataMigrationManager, ) : BaseViewModel() { @@ -127,8 +125,6 @@ class PinVerificationViewModel( val isValid = verifyPinUseCase.verifyPin(pin) if (isValid) { - metadataManager.loadIndex() - if (metadataMigrationManager.needsMigration()) { runDataMigration() } 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 08b0c32..1ad9318 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 @@ -111,7 +111,7 @@ fun CameraControls( cameraController.stopRecording() vibrateDevice(context) } else { - val outputFile = cameraController.startRecording(context) + val outputFile = cameraController.startRecording(context, cameraRotation.toInt()) if (outputFile != null) { Timber.i("Recording to: ${outputFile.absolutePath}") vibrateDevice(context) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraState.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraState.kt index 82932e0..de9c7a6 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraState.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraState.kt @@ -3,6 +3,7 @@ package com.darkrockstudios.app.securecamera.camera import android.annotation.SuppressLint import android.content.Context import android.graphics.RectF +import android.view.Surface import androidx.camera.core.* import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.video.* @@ -260,9 +261,12 @@ class CameraState internal constructor( * 2. On recording complete, the temp file is encrypted to .secv format * 3. The temp file is securely deleted after encryption * 4. Metadata entry is added to the encrypted sidecar + * + * @param context The application context + * @param deviceRotation The current device rotation in degrees (0, 90, 180, 270) from accelerometer */ @SuppressLint("MissingPermission") - fun startRecording(context: Context): File? { + fun startRecording(context: Context, deviceRotation: Int = 0): File? { val videoCapture = this.videoCapture ?: run { Timber.e("VideoCapture not initialized") return null @@ -273,6 +277,14 @@ class CameraState internal constructor( return null } + val surfaceRotation = when (deviceRotation) { + 90 -> Surface.ROTATION_90 + 180 -> Surface.ROTATION_180 + 270 -> Surface.ROTATION_270 + else -> Surface.ROTATION_0 + } + videoCapture.targetRotation = surfaceRotation + // Create output directories val videosDir = File(context.filesDir, "videos") if (!videosDir.exists()) { diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntry.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntry.kt index dbc9c0b..cd23fea 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntry.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntry.kt @@ -148,6 +148,18 @@ data class MediaMetadataEntry( return buffer.array() } + /** + * Creates an "empty" entry for pre-allocated slots. + * The entry will have STATUS_EMPTY and all other fields zeroed. + */ + fun emptyEntryBytes(): ByteArray { + val buffer = ByteBuffer.allocate(SecmFileFormat.ENTRY_PLAINTEXT_SIZE) + buffer.order(ByteOrder.LITTLE_ENDIAN) + buffer.put(SecmFileFormat.STATUS_EMPTY) + // Rest is already zero-filled + return buffer.array() + } + /** * Extracts just the status byte from an entry's plaintext. */ diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt index b3476e9..bc9414b 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt @@ -332,24 +332,63 @@ class MetadataManager( } /** - * Allocates a new slot at the end of the file. + * Allocates a new slot by growing the file in chunks. + * + * @return The first newly allocated slot (for immediate use) */ private suspend fun allocateNewSlot(): Int { - val newSlot = capacity - capacity++ + val currentCapacity = capacity + val firstNewSlot = currentCapacity + + // Calculate growth: double current capacity, bounded by min/max + val growthAmount = when { + currentCapacity == 0 -> MIN_GROWTH_SLOTS + currentCapacity < MAX_GROWTH_SLOTS -> currentCapacity.coerceAtLeast(MIN_GROWTH_SLOTS) + else -> MAX_GROWTH_SLOTS + } + + val newCapacity = currentCapacity + growthAmount - // Update header capacity withContext(Dispatchers.IO) { - indexFile?.let { raf -> - raf.seek(SecmFileFormat.HEADER_OFFSET_CAPACITY.toLong()) - val buffer = ByteBuffer.allocate(4) - buffer.order(ByteOrder.LITTLE_ENDIAN) - buffer.putInt(capacity) - raf.write(buffer.array()) + val raf = indexFile ?: error("Index file not open") + val keyBytes = encryptionScheme.getDerivedKey() + + // Write empty encrypted entries for all new slots + for (slot in currentCapacity until newCapacity) { + val plaintext = MediaMetadataEntry.emptyEntryBytes() + val encrypted = encryptEntry(plaintext, keyBytes) + raf.seek(SecmFileFormat.entryOffset(slot)) + raf.write(encrypted) } + + // Update header capacity + raf.seek(SecmFileFormat.HEADER_OFFSET_CAPACITY.toLong()) + val buffer = ByteBuffer.allocate(4) + buffer.order(ByteOrder.LITTLE_ENDIAN) + buffer.putInt(newCapacity) + raf.write(buffer.array()) + + obfuscateIndexTimestamp() } - return newSlot + // Add all new slots except the first one to free list + for (slot in (firstNewSlot + 1) until newCapacity) { + freeSlots.add(slot) + } + + capacity = newCapacity + + Timber.d("Grew metadata capacity from $currentCapacity to $newCapacity (+$growthAmount slots)") + + return firstNewSlot + } + + companion object { + /** Minimum number of slots to allocate at once */ + private const val MIN_GROWTH_SLOTS = 64 + + /** Maximum number of slots to add in a single growth operation */ + private const val MAX_GROWTH_SLOTS = 512 } /** diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/CreatePinUseCase.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/CreatePinUseCase.kt index a191053..e22794c 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/CreatePinUseCase.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/CreatePinUseCase.kt @@ -1,6 +1,7 @@ package com.darkrockstudios.app.securecamera.usecases import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository +import com.darkrockstudios.app.securecamera.metadata.MetadataManager import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource import com.darkrockstudios.app.securecamera.security.SchemeConfig import com.darkrockstudios.app.securecamera.security.pin.PinRepository @@ -11,7 +12,8 @@ class CreatePinUseCase( private val encryptionScheme: EncryptionScheme, private val pinRepository: PinRepository, private val preferencesDataSource: AppSettingsDataSource, - private val authorizePinUseCase: AuthorizePinUseCase + private val authorizePinUseCase: AuthorizePinUseCase, + private val metadataManager: MetadataManager, ) { suspend fun createPin(pin: String, schemeConfig: SchemeConfig): Boolean { pinRepository.setAppPin(pin, schemeConfig) @@ -19,6 +21,7 @@ class CreatePinUseCase( return if (hashedPin != null) { authorizationRepository.createKey(pin, hashedPin) encryptionScheme.deriveAndCacheKey(pin, hashedPin) + metadataManager.loadIndex() preferencesDataSource.setIntroCompleted(true) true } else { diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCase.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCase.kt index 3048356..26fedb5 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCase.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCase.kt @@ -2,6 +2,7 @@ package com.darkrockstudios.app.securecamera.usecases import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository import com.darkrockstudios.app.securecamera.camera.SecureImageRepository +import com.darkrockstudios.app.securecamera.metadata.MetadataManager import com.darkrockstudios.app.securecamera.security.pin.PinRepository import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme @@ -11,6 +12,7 @@ class VerifyPinUseCase( private val pinRepository: PinRepository, private val encryptionScheme: EncryptionScheme, private val authorizePinUseCase: AuthorizePinUseCase, + private val metadataManager: MetadataManager, ) { suspend fun verifyPin(pin: String): Boolean { if (pinRepository.hasPoisonPillPin() && pinRepository.verifyPoisonPillPin(pin)) { @@ -22,6 +24,7 @@ class VerifyPinUseCase( val hashedPin = authorizePinUseCase.authorizePin(pin) return if (hashedPin != null) { encryptionScheme.deriveAndCacheKey(pin, hashedPin) + metadataManager.loadIndex() true } else { authRepository.incrementFailedAttempts() diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt index 55616e1..0431a5d 100644 --- a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt @@ -6,7 +6,9 @@ import android.graphics.BitmapFactory import com.ashampoo.kim.model.GpsCoordinates import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository import com.darkrockstudios.app.securecamera.camera.* +import com.darkrockstudios.app.securecamera.metadata.MetadataManager import com.darkrockstudios.app.securecamera.preferences.HashedPin +import com.darkrockstudios.app.securecamera.security.FileTimestampObfuscator import com.darkrockstudios.app.securecamera.security.pin.PinRepository import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme import io.mockk.* @@ -34,6 +36,8 @@ class SecureImageRepositoryTest { private lateinit var secureImageRepository: SecureImageRepository private lateinit var thumbnailCache: ThumbnailCache private lateinit var encryptionScheme: EncryptionScheme + private lateinit var metadataManager: MetadataManager + private lateinit var fileTimestampObfuscator: FileTimestampObfuscator @Before fun setup() { @@ -42,6 +46,8 @@ class SecureImageRepositoryTest { authorizationRepository = mockk(relaxed = true) thumbnailCache = mockk(relaxed = true) encryptionScheme = mockk() + metadataManager = mockk(relaxed = true) + fileTimestampObfuscator = mockk(relaxed = true) // Mock the filesDir and cacheDir val filesDir = tempFolder.newFolder("files") @@ -98,6 +104,8 @@ class SecureImageRepositoryTest { appContext = context, thumbnailCache = thumbnailCache, encryptionScheme = encryptionScheme, + metadataManager = metadataManager, + fileTimestampObfuscator = fileTimestampObfuscator, ) } @@ -176,7 +184,7 @@ class SecureImageRepositoryTest { } @Test - fun `deleteImage should remove the photo file and thumbnail`() { + fun `deleteImage should remove the photo file and thumbnail`() = runTest { // Given val galleryDir = secureImageRepository.getGalleryDirectory() galleryDir.mkdirs() @@ -199,7 +207,7 @@ class SecureImageRepositoryTest { } @Test - fun `deleteImage should return false when photo does not exist`() { + fun `deleteImage should return false when photo does not exist`() = runTest { // Given val galleryDir = secureImageRepository.getGalleryDirectory() galleryDir.mkdirs() @@ -442,7 +450,7 @@ class SecureImageRepositoryTest { } @Test - fun `deleteAllImages should delete all photos`() { + fun `deleteAllImages should delete all photos`() = runTest { // Given val galleryDir = secureImageRepository.getGalleryDirectory() galleryDir.mkdirs() diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntryTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntryTest.kt new file mode 100644 index 0000000..350f148 --- /dev/null +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntryTest.kt @@ -0,0 +1,163 @@ +package com.darkrockstudios.app.securecamera.metadata + +import com.darkrockstudios.app.securecamera.camera.MediaType +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class MediaMetadataEntryTest { + + @Test + fun `toBytes and fromBytes should round-trip photo entry`() { + val entry = MediaMetadataEntry( + filename = "img_abc123.jpg", + originalTimestamp = 1234567890123L, + mediaType = MediaType.PHOTO, + originalFilename = "IMG_20240101_120000.jpg", + fileSize = 1024000L + ) + + val bytes = entry.toBytes() + val restored = MediaMetadataEntry.fromBytes(bytes) + + assertNotNull(restored) + assertEquals(entry.filename, restored.filename) + assertEquals(entry.originalTimestamp, restored.originalTimestamp) + assertEquals(entry.mediaType, restored.mediaType) + assertEquals(entry.originalFilename, restored.originalFilename) + assertEquals(entry.fileSize, restored.fileSize) + } + + @Test + fun `toBytes and fromBytes should round-trip video entry`() { + val entry = MediaMetadataEntry( + filename = "vid_def456.secv", + originalTimestamp = 9876543210987L, + mediaType = MediaType.VIDEO, + originalFilename = "VID_20240615_183000.mp4", + fileSize = 50000000L + ) + + val bytes = entry.toBytes() + val restored = MediaMetadataEntry.fromBytes(bytes) + + assertNotNull(restored) + assertEquals(entry.filename, restored.filename) + assertEquals(entry.originalTimestamp, restored.originalTimestamp) + assertEquals(entry.mediaType, restored.mediaType) + assertEquals(entry.originalFilename, restored.originalFilename) + assertEquals(entry.fileSize, restored.fileSize) + } + + @Test + fun `toBytes and fromBytes should handle null originalFilename`() { + val entry = MediaMetadataEntry( + filename = "img_xyz789.jpg", + originalTimestamp = 1111111111111L, + mediaType = MediaType.PHOTO, + originalFilename = null, + fileSize = 500000L + ) + + val bytes = entry.toBytes() + val restored = MediaMetadataEntry.fromBytes(bytes) + + assertNotNull(restored) + assertNull(restored.originalFilename) + } + + @Test + fun `toBytes should produce correct size`() { + val entry = MediaMetadataEntry( + filename = "test.jpg", + originalTimestamp = 0L, + mediaType = MediaType.PHOTO + ) + + val bytes = entry.toBytes() + assertEquals(SecmFileFormat.ENTRY_PLAINTEXT_SIZE, bytes.size) + } + + @Test + fun `toBytes should set status to active`() { + val entry = MediaMetadataEntry( + filename = "test.jpg", + originalTimestamp = 0L, + mediaType = MediaType.PHOTO + ) + + val bytes = entry.toBytes() + assertEquals(SecmFileFormat.STATUS_ACTIVE, MediaMetadataEntry.getStatus(bytes)) + } + + @Test + fun `deletedEntryBytes should produce correct size`() { + val bytes = MediaMetadataEntry.deletedEntryBytes() + assertEquals(SecmFileFormat.ENTRY_PLAINTEXT_SIZE, bytes.size) + } + + @Test + fun `deletedEntryBytes should have deleted status`() { + val bytes = MediaMetadataEntry.deletedEntryBytes() + assertEquals(SecmFileFormat.STATUS_DELETED, MediaMetadataEntry.getStatus(bytes)) + } + + @Test + fun `emptyEntryBytes should produce correct size`() { + val bytes = MediaMetadataEntry.emptyEntryBytes() + assertEquals(SecmFileFormat.ENTRY_PLAINTEXT_SIZE, bytes.size) + } + + @Test + fun `emptyEntryBytes should have empty status`() { + val bytes = MediaMetadataEntry.emptyEntryBytes() + assertEquals(SecmFileFormat.STATUS_EMPTY, MediaMetadataEntry.getStatus(bytes)) + } + + @Test + fun `fromBytes should return null for deleted entry`() { + val bytes = MediaMetadataEntry.deletedEntryBytes() + val result = MediaMetadataEntry.fromBytes(bytes) + assertNull(result) + } + + @Test + fun `fromBytes should return null for empty entry`() { + val bytes = MediaMetadataEntry.emptyEntryBytes() + val result = MediaMetadataEntry.fromBytes(bytes) + assertNull(result) + } + + @Test + fun `toBytes should truncate long filename`() { + val longFilename = "a".repeat(100) + val entry = MediaMetadataEntry( + filename = longFilename, + originalTimestamp = 0L, + mediaType = MediaType.PHOTO + ) + + val bytes = entry.toBytes() + val restored = MediaMetadataEntry.fromBytes(bytes) + + assertNotNull(restored) + assertEquals(SecmFileFormat.FILENAME_MAX_LENGTH, restored.filename.length) + } + + @Test + fun `toBytes should handle maximum file size`() { + val entry = MediaMetadataEntry( + filename = "large.secv", + originalTimestamp = 0L, + mediaType = MediaType.VIDEO, + fileSize = Long.MAX_VALUE + ) + + val bytes = entry.toBytes() + val restored = MediaMetadataEntry.fromBytes(bytes) + + assertNotNull(restored) + assertEquals(Long.MAX_VALUE, restored.fileSize) + } +} diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataGrowthTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataGrowthTest.kt new file mode 100644 index 0000000..1b5056e --- /dev/null +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataGrowthTest.kt @@ -0,0 +1,121 @@ +package com.darkrockstudios.app.securecamera.metadata + +import org.junit.Test +import kotlin.test.assertEquals + +/** + * Tests for the metadata file growth strategy. + * + * The growth strategy uses doubling with bounds to reduce information leakage + * from file size changes. This makes it harder to correlate file size with + * actual photo count or deletion activity. + */ +class MetadataGrowthTest { + + companion object { + // These must match MetadataManager.Companion values + private const val MIN_GROWTH_SLOTS = 64 + private const val MAX_GROWTH_SLOTS = 512 + } + + /** + * Replicates the growth calculation from MetadataManager.allocateNewSlot() + */ + private fun calculateGrowthAmount(currentCapacity: Int): Int { + return when { + currentCapacity == 0 -> MIN_GROWTH_SLOTS + currentCapacity < MAX_GROWTH_SLOTS -> currentCapacity.coerceAtLeast(MIN_GROWTH_SLOTS) + else -> MAX_GROWTH_SLOTS + } + } + + @Test + fun `first allocation should create MIN_GROWTH_SLOTS`() { + val growth = calculateGrowthAmount(0) + assertEquals(MIN_GROWTH_SLOTS, growth) + } + + @Test + fun `growth from 64 should double to 64`() { + val growth = calculateGrowthAmount(64) + assertEquals(64, growth) + } + + @Test + fun `growth from 128 should double to 128`() { + val growth = calculateGrowthAmount(128) + assertEquals(128, growth) + } + + @Test + fun `growth from 256 should double to 256`() { + val growth = calculateGrowthAmount(256) + assertEquals(256, growth) + } + + @Test + fun `growth from 512 should cap at MAX_GROWTH_SLOTS`() { + val growth = calculateGrowthAmount(512) + assertEquals(MAX_GROWTH_SLOTS, growth) + } + + @Test + fun `growth from 1024 should cap at MAX_GROWTH_SLOTS`() { + val growth = calculateGrowthAmount(1024) + assertEquals(MAX_GROWTH_SLOTS, growth) + } + + @Test + fun `growth from small capacity should use minimum`() { + // If capacity is less than MIN_GROWTH_SLOTS, still grow by MIN_GROWTH_SLOTS + val growth = calculateGrowthAmount(32) + assertEquals(MIN_GROWTH_SLOTS, growth) + } + + @Test + fun `capacity progression should follow expected pattern`() { + var capacity = 0 + val expectedProgression = listOf( + 64, // 0 -> 64 + 128, // 64 -> 128 + 256, // 128 -> 256 + 512, // 256 -> 512 + 1024, // 512 -> 1024 + 1536, // 1024 -> 1536 (capped growth) + 2048, // 1536 -> 2048 (capped growth) + ) + + for (expected in expectedProgression) { + val growth = calculateGrowthAmount(capacity) + capacity += growth + assertEquals(expected, capacity, "Capacity should be $expected after growth from ${capacity - growth}") + } + } + + @Test + fun `file size should grow in predictable steps`() { + // Each slot is ENTRY_BLOCK_SIZE (168 bytes) + // Plus HEADER_SIZE (32 bytes) for the file + val capacities = listOf(64, 128, 256, 512, 1024) + val expectedFileSizes = capacities.map { capacity -> + SecmFileFormat.HEADER_SIZE + (capacity * SecmFileFormat.ENTRY_BLOCK_SIZE) + } + + // Verify the file sizes are what we expect + assertEquals(32 + 64 * 168, expectedFileSizes[0]) // ~10.8 KB + assertEquals(32 + 128 * 168, expectedFileSizes[1]) // ~21.5 KB + assertEquals(32 + 256 * 168, expectedFileSizes[2]) // ~43 KB + assertEquals(32 + 512 * 168, expectedFileSizes[3]) // ~86 KB + assertEquals(32 + 1024 * 168, expectedFileSizes[4]) // ~172 KB + } + + @Test + fun `free slots after growth should be growthAmount minus 1`() { + // When we grow, we use one slot immediately and add the rest to freeSlots + val currentCapacity = 64 + val growth = calculateGrowthAmount(currentCapacity) + val freeSlotsAdded = growth - 1 + + assertEquals(63, freeSlotsAdded, "Should add growth-1 slots to free list") + } +} diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/metadata/SecmFileFormatTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/metadata/SecmFileFormatTest.kt new file mode 100644 index 0000000..922bffe --- /dev/null +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/metadata/SecmFileFormatTest.kt @@ -0,0 +1,44 @@ +package com.darkrockstudios.app.securecamera.metadata + +import org.junit.Test +import kotlin.test.assertEquals + +class SecmFileFormatTest { + + @Test + fun `entryOffset should return header size for slot 0`() { + val offset = SecmFileFormat.entryOffset(0) + assertEquals(SecmFileFormat.HEADER_SIZE.toLong(), offset) + } + + @Test + fun `entryOffset should return correct offset for slot 1`() { + val offset = SecmFileFormat.entryOffset(1) + val expected = SecmFileFormat.HEADER_SIZE + SecmFileFormat.ENTRY_BLOCK_SIZE + assertEquals(expected.toLong(), offset) + } + + @Test + fun `entryOffset should return correct offset for arbitrary slot`() { + val slot = 100 + val offset = SecmFileFormat.entryOffset(slot) + val expected = SecmFileFormat.HEADER_SIZE + (slot * SecmFileFormat.ENTRY_BLOCK_SIZE) + assertEquals(expected.toLong(), offset) + } + + @Test + fun `entry block size should accommodate IV plus ciphertext plus auth tag`() { + val expected = SecmFileFormat.IV_SIZE + SecmFileFormat.ENTRY_PLAINTEXT_SIZE + SecmFileFormat.AUTH_TAG_SIZE + assertEquals(expected, SecmFileFormat.ENTRY_BLOCK_SIZE) + } + + @Test + fun `header size should be 32 bytes`() { + assertEquals(32, SecmFileFormat.HEADER_SIZE) + } + + @Test + fun `entry plaintext size should be 140 bytes`() { + assertEquals(140, SecmFileFormat.ENTRY_PLAINTEXT_SIZE) + } +} diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCaseTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCaseTest.kt index 569e179..92001d1 100644 --- a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCaseTest.kt +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCaseTest.kt @@ -2,6 +2,7 @@ package com.darkrockstudios.app.securecamera.usecases import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository import com.darkrockstudios.app.securecamera.camera.SecureImageRepository +import com.darkrockstudios.app.securecamera.metadata.MetadataManager import com.darkrockstudios.app.securecamera.security.pin.PinRepository import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme import io.mockk.* @@ -21,6 +22,7 @@ class VerifyPinUseCaseTest { private lateinit var verifyPinUseCase: VerifyPinUseCase private lateinit var encryptionScheme: EncryptionScheme private lateinit var authorizePinUseCase: AuthorizePinUseCase + private lateinit var metadataManager: MetadataManager @Before fun setup() { @@ -29,12 +31,14 @@ class VerifyPinUseCaseTest { pinRepository = mockk() authorizePinUseCase = mockk() encryptionScheme = mockk(relaxed = true) + metadataManager = mockk(relaxed = true) verifyPinUseCase = VerifyPinUseCase( authRepository = authManager, imageRepository = imageManager, pinRepository = pinRepository, encryptionScheme = encryptionScheme, authorizePinUseCase = authorizePinUseCase, + metadataManager = metadataManager, ) } From 6bd62b3c698e0d11706b89c8b677a6b2b83a7fd8 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 1 Feb 2026 23:19:04 -0800 Subject: [PATCH 29/47] Fix photo date test --- .../imagemanager/SecureImageRepositoryTest.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt index 0431a5d..38831a5 100644 --- a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt @@ -723,7 +723,7 @@ class SecureImageRepositoryTest { val galleryDir = secureImageRepository.getGalleryDirectory() galleryDir.mkdirs() - val photoName = "photo_20230101_120000_00.jpg" + val photoName = "img_550e8400e29b41d4a716446655440000.jpg" val photoFile = File(galleryDir, photoName) // Create an encrypted file @@ -733,10 +733,14 @@ class SecureImageRepositoryTest { targetFile = photoFile, ) + // Timestamp for 2023-01-01 12:00:00 UTC + val expectedTimestamp = 1672574400000L + val photoDef = PhotoDef( photoName = photoName, photoFormat = "jpg", - photoFile = photoFile + photoFile = photoFile, + metadataTimestamp = expectedTimestamp ) // When @@ -744,13 +748,8 @@ class SecureImageRepositoryTest { // Then assertEquals(photoName, result.name) - // The date should be parsed from the photo name - assertEquals(2023, result.dateTaken.year + 1900) // Java Date year is offset by 1900 - assertEquals(0, result.dateTaken.month) // Java Date month is 0-based (0 = January) - assertEquals(1, result.dateTaken.date) // day of month - assertEquals(12, result.dateTaken.hours) - assertEquals(0, result.dateTaken.minutes) - assertEquals(0, result.dateTaken.seconds) + // The date should come from the metadataTimestamp + assertEquals(expectedTimestamp, result.dateTaken.time) // Check the new properties assertNull(result.orientation) assertNull(result.location) From 51298e8dd33a133046fa8086562ce5bdb14298dc Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 1 Feb 2026 23:21:30 -0800 Subject: [PATCH 30/47] Upgrade zoomable --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b43a662..54d45ec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,7 +35,7 @@ koin-bom = "4.1.1" cryptographyBom = "0.5.0" datastorePreferences = "1.2.0" timber = "5.0.1" -zoomable = "2.9.0" +zoomable = "2.10.0" media3 = "1.9.1" camerax = "1.5.3" foundation = "1.10.2" From d8da9c9e64ce35f2e15fe9f93cdff7dd1f4b694d Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 1 Feb 2026 23:21:40 -0800 Subject: [PATCH 31/47] Release prep --- fastlane/metadata/android/en-US/changelogs/28.txt | 3 +++ gradle/libs.versions.toml | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/28.txt diff --git a/fastlane/metadata/android/en-US/changelogs/28.txt b/fastlane/metadata/android/en-US/changelogs/28.txt new file mode 100644 index 0000000..c32f941 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/28.txt @@ -0,0 +1,3 @@ +- Improved security around photo metadata +- Allow alphanumeric PINs +- Fix landscape photos and videos orientation \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 54d45ec..4c70ec6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -versionCode = "27" -versionName = "4.0.1" +versionCode = "28" +versionName = "4.1.0" targetSdk = "36" compileSdk = "36" From 704364020275ce29eaadfe34757c508894448882 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 2 Feb 2026 00:01:05 -0800 Subject: [PATCH 32/47] Update failing test --- .../app/securecamera/camera/PhotoDefTest.kt | 51 ++++++++----------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/camera/PhotoDefTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/camera/PhotoDefTest.kt index 886b04b..2b9edca 100644 --- a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/camera/PhotoDefTest.kt +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/camera/PhotoDefTest.kt @@ -1,60 +1,53 @@ package com.darkrockstudios.app.securecamera.camera import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test import java.io.File -import java.text.SimpleDateFormat import java.util.* class PhotoDefTest { @Test - fun `dateTaken parses valid photoName correctly`() { - // Create a known date - val calendar = Calendar.getInstance() - calendar.set(2023, Calendar.JANUARY, 15, 10, 30, 45) - calendar.set(Calendar.MILLISECOND, 500) - val expectedDate = calendar.time - - // Format the date as it would be in a photoName - val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss_SS", Locale.US) - val dateString = dateFormat.format(expectedDate) - val photoName = "photo_$dateString.jpg" - - // Create a PhotoDef with this photoName + fun `dateTaken returns date from metadataTimestamp when provided`() { + // Create a known timestamp + val expectedTimestamp = 1673782245500L // 2023-01-15 10:30:45.500 UTC + + // Create a PhotoDef with UUID-based filename and metadata timestamp val photoDef = PhotoDef( - photoName = photoName, + photoName = "img_550e8400e29b41d4a716446655440000.jpg", photoFormat = "jpg", - photoFile = File("dummy/path") + photoFile = File("dummy/path"), + metadataTimestamp = expectedTimestamp ) - // Get the parsed date - val parsedDate = photoDef.dateTaken() + // Get the date + val result = photoDef.dateTaken() - // Compare the dates (using string representation to avoid millisecond precision issues) - assertEquals(dateFormat.format(expectedDate), dateFormat.format(parsedDate)) + // Verify the timestamp matches + assertEquals(expectedTimestamp, result.time) } @Test - fun `dateTaken handles invalid photoName gracefully`() { - // Create a PhotoDef with an invalid photoName + fun `dateTaken returns current date when metadataTimestamp is null`() { + // Create a PhotoDef without metadata timestamp val photoDef = PhotoDef( - photoName = "invalid_photo_name.jpg", + photoName = "img_550e8400e29b41d4a716446655440000.jpg", photoFormat = "jpg", - photoFile = File("dummy/path") + photoFile = File("dummy/path"), + metadataTimestamp = null ) // Get the current time before calling dateTaken val beforeTime = Date() - // Call dateTaken which should return current date for invalid format - val parsedDate = photoDef.dateTaken() + // Call dateTaken which should return current date + val result = photoDef.dateTaken() // Get the current time after calling dateTaken val afterTime = Date() // Verify the returned date is between beforeTime and afterTime - // This is a loose check since we can't predict the exact time it will use - assert(parsedDate.time >= beforeTime.time - 1000 && parsedDate.time <= afterTime.time + 1000) + assertTrue(result.time >= beforeTime.time - 1000 && result.time <= afterTime.time + 1000) } -} \ No newline at end of file +} From b2b3b12d102e1a6853bafcc7efdd9b65cbe35d81 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 5 Feb 2026 21:51:50 -0800 Subject: [PATCH 33/47] Fix double photo take --- .../darkrockstudios/app/securecamera/camera/CameraControls.kt | 1 + 1 file changed, 1 insertion(+) 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 1ad9318..2e4bd26 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 @@ -125,6 +125,7 @@ fun CameraControls( LaunchedEffect(capturePhoto.value) { if (capturePhoto.value != null) { doCapturePhoto() + capturePhoto.value = null } } From 324a2a76f0e9b0cd140b22a1958ef58ac217efd9 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 5 Feb 2026 21:54:44 -0800 Subject: [PATCH 34/47] Release 4.1.1 --- fastlane/metadata/android/en-US/changelogs/29.txt | 4 ++++ gradle/libs.versions.toml | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/29.txt diff --git a/fastlane/metadata/android/en-US/changelogs/29.txt b/fastlane/metadata/android/en-US/changelogs/29.txt new file mode 100644 index 0000000..b91482d --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/29.txt @@ -0,0 +1,4 @@ +- Improved security around photo metadata +- Allow alphanumeric PINs +- Fix landscape photos and videos orientation +- Fix accidental photo take after volume shutter button press diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4c70ec6..c6eb869 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -versionCode = "28" -versionName = "4.1.0" +versionCode = "29" +versionName = "4.1.1" targetSdk = "36" compileSdk = "36" From b6b6280e62f4fea58b0ceb958e1f2b6d21457ea3 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 15 Feb 2026 19:24:08 -0800 Subject: [PATCH 35/47] Poison Pill PIN now supports alpha-numeric PINs --- .../settings/PoisonPillPinCreationDialog.kt | 28 +++++++++++++------ .../settings/SettingsViewModel.kt | 9 +++++- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/PoisonPillPinCreationDialog.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/PoisonPillPinCreationDialog.kt index 2b33bd0..fe4dc5b 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/PoisonPillPinCreationDialog.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/PoisonPillPinCreationDialog.kt @@ -77,16 +77,21 @@ fun PoisonPillPinCreationDialog( // PIN input OutlinedTextField( value = pin, - onValueChange = { - if (it.length <= uiState.pinSize.max() && it.all { char -> char.isDigit() }) { - pin = it + onValueChange = { newPin -> + val isValid = if (uiState.alphanumericPinEnabled) { + newPin.all { char -> char.isLetterOrDigit() } + } else { + newPin.all { char -> char.isDigit() } + } + if (newPin.length <= uiState.pinSize.max() && isValid) { + pin = newPin showError = null } }, label = { Text(stringResource(R.string.pin_creation_hint)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword, + keyboardType = if (uiState.alphanumericPinEnabled) KeyboardType.Password else KeyboardType.NumberPassword, imeAction = ImeAction.Next ), singleLine = true, @@ -98,16 +103,21 @@ fun PoisonPillPinCreationDialog( // Confirm PIN input OutlinedTextField( value = confirmPin, - onValueChange = { - if (it.length <= uiState.pinSize.max() && it.all { char -> char.isDigit() }) { - confirmPin = it + onValueChange = { newConfirmPin -> + val isValid = if (uiState.alphanumericPinEnabled) { + newConfirmPin.all { char -> char.isLetterOrDigit() } + } else { + newConfirmPin.all { char -> char.isDigit() } + } + if (newConfirmPin.length <= uiState.pinSize.max() && isValid) { + confirmPin = newConfirmPin showError = null } }, label = { Text(stringResource(R.string.pin_creation_confirm_hint)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword, + keyboardType = if (uiState.alphanumericPinEnabled) KeyboardType.Password else KeyboardType.NumberPassword, imeAction = ImeAction.Done ), singleLine = true, @@ -158,4 +168,4 @@ fun PoisonPillPinCreationDialog( } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsViewModel.kt index 15bff54..2b37888 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SettingsViewModel.kt @@ -65,6 +65,12 @@ class SettingsViewModel( _uiState.update { it.copy(locationPermissionStatus = status) } } } + + viewModelScope.launch { + preferences.alphanumericPinEnabled.collect { enabled -> + _uiState.update { it.copy(alphanumericPinEnabled = enabled) } + } + } } private fun checkPoisonPillStatus() { @@ -193,7 +199,7 @@ class SettingsViewModel( } suspend fun validatePoisonPillPin(pin: String, confirmPin: String): String? { - val strongPin = pinStrengthCheck.isPinStrongEnough(pin) + val strongPin = pinStrengthCheck.isPinStrongEnough(pin, uiState.value.alphanumericPinEnabled) return if (pin != confirmPin || (pin.length in uiState.value.pinSize).not()) { appContext.getString(R.string.pin_creation_error) } else if (isSameAsAuthPin(pin)) { @@ -223,4 +229,5 @@ data class SettingsUiState( val poisonPillRemoved: Boolean = false, val securityLevel: SecurityLevel = SecurityLevel.SOFTWARE, val pinSize: IntRange, + val alphanumericPinEnabled: Boolean = false, ) From 3063549ebd0be21dc6ee5183fb5f9863290233d5 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 15 Feb 2026 19:26:02 -0800 Subject: [PATCH 36/47] Library upgrades --- gradle/libs.versions.toml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c6eb869..e431984 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,25 +20,25 @@ lifecycleViewModel = "2.10.0" materialIconsExtended = "1.7.8" mockk = "1.14.9" kim = "0.26.2" -kotlin = "2.3.0" +kotlin = "2.3.10" kotlinx-serialization = "1.10.0" -runtimeLivedata = "1.10.2" +runtimeLivedata = "1.10.3" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" -activityCompose = "1.12.3" -composeBom = "2026.01.01" -navigation3 = "1.0.0" +activityCompose = "1.12.4" +composeBom = "2026.02.00" +navigation3 = "1.0.1" nav3Material = "1.0.0-SNAPSHOT" lifecycleViewmodelNav3 = "2.10.0" koin-bom = "4.1.1" cryptographyBom = "0.5.0" datastorePreferences = "1.2.0" timber = "5.0.1" -zoomable = "2.10.0" -media3 = "1.9.1" +zoomable = "2.11.0" +media3 = "1.9.2" camerax = "1.5.3" -foundation = "1.10.2" +foundation = "1.10.3" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } From bfe9a9062ecace6102b54351ce0e0cb5adb415f9 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 15 Feb 2026 20:57:01 -0800 Subject: [PATCH 37/47] Library upgrades --- gradle/libs.versions.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e431984..0e170d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,6 @@ junitVersion = "1.3.0" activityCompose = "1.12.4" composeBom = "2026.02.00" navigation3 = "1.0.1" -nav3Material = "1.0.0-SNAPSHOT" lifecycleViewmodelNav3 = "2.10.0" koin-bom = "4.1.1" cryptographyBom = "0.5.0" @@ -100,7 +99,6 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } 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] From f840aa9f06912a321ac33d278d5d63ce891de40a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 15 Feb 2026 20:58:20 -0800 Subject: [PATCH 38/47] Nicer back effect --- .../securecamera/navigation/AppNavigation.kt | 62 ++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) 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 c787013..f43b144 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 @@ -1,17 +1,24 @@ package com.darkrockstudios.app.securecamera.navigation +import android.os.Build +import android.view.RoundedCorner +import androidx.compose.animation.* import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entryProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.* import androidx.navigation3.ui.NavDisplay import com.darkrockstudios.app.securecamera.R import com.darkrockstudios.app.securecamera.about.AboutContent @@ -27,6 +34,26 @@ import com.darkrockstudios.app.securecamera.settings.SettingsContent import com.darkrockstudios.app.securecamera.viewphoto.ViewPhotoContent import kotlin.io.encoding.ExperimentalEncodingApi +@Composable +private fun rememberScreenCornerRadius(): Dp { + val view = LocalView.current + val density = LocalDensity.current + return remember { + val radius = 16.dp + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val insets = view.rootWindowInsets + val corner = insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT) + if (corner != null) { + with(density) { corner.radius.toDp() } + } else { + radius + } + } else { + radius + } + } +} + @OptIn(ExperimentalEncodingApi::class) @Composable fun AppNavHost( @@ -43,10 +70,39 @@ fun AppNavHost( LaunchedEffect(Unit) { authManager.checkSessionValidity() } + val cornerRadius = rememberScreenCornerRadius() + val roundedCornerDecorator = remember(cornerRadius) { + NavEntryDecorator { entry -> + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(cornerRadius)) + ) { + entry.Content() + } + } + } + NavDisplay( backStack = backStack, onBack = { if (backStack.isNotEmpty()) backStack.removeAt(backStack.lastIndex) }, modifier = modifier, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + roundedCornerDecorator, + ), + transitionSpec = { + (slideInHorizontally { it } + fadeIn()) togetherWith + (slideOutHorizontally { -it } + fadeOut()) + }, + popTransitionSpec = { + (slideInHorizontally { -it } + fadeIn()) togetherWith + (slideOutHorizontally { it } + fadeOut()) + }, + predictivePopTransitionSpec = { + (slideInHorizontally { -it / 10 } + fadeIn()) togetherWith + (scaleOut(targetScale = 0.9f) + fadeOut()) + }, entryProvider = entryProvider { entry { IntroductionContent( From 34972af0898d26324b739448525e3a56cb11f25d Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 16 Feb 2026 12:19:52 -0800 Subject: [PATCH 39/47] Manual regions don't use the face oval --- .../obfuscation/ObfuscationTools.kt | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscationTools.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscationTools.kt index d45cceb..300d799 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscationTools.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscationTools.kt @@ -6,9 +6,9 @@ import android.renderscript.Allocation import android.renderscript.Element import android.renderscript.RenderScript import android.renderscript.ScriptIntrinsicBlur +import androidx.core.graphics.createBitmap import androidx.core.graphics.scale import java.security.SecureRandom -import androidx.core.graphics.createBitmap enum class MaskMode { BLACKOUT, @@ -43,12 +43,13 @@ fun coerceRectToBitmap(rect: Rect, bitmap: Bitmap): Rect { fun maskFace(bitmap: Bitmap, region: Region, context: Context, vararg modes: MaskMode) { val rect = region.rect val safeRect = coerceRectToBitmap(rect, bitmap) + val useOvalMask = region is FaceRegion modes.forEach { mode -> when (mode) { - MaskMode.BLACKOUT -> blackout(bitmap, safeRect) - MaskMode.PIXELATE -> pixelate(bitmap, safeRect, region) - MaskMode.NOISE -> noise(bitmap, safeRect) - MaskMode.BLUR -> blur(bitmap, safeRect, context) + MaskMode.BLACKOUT -> blackout(bitmap, safeRect, useOvalMask) + MaskMode.PIXELATE -> pixelate(bitmap, safeRect, region, useOvalMask = useOvalMask) + MaskMode.NOISE -> noise(bitmap, safeRect, useOvalMask) + MaskMode.BLUR -> blur(bitmap, safeRect, context, useOvalMask = useOvalMask) } } } @@ -73,7 +74,7 @@ private fun applyOvalMask(sourceBitmap: Bitmap, rect: Rect): Bitmap { return maskedBitmap } -private fun blackout(bitmap: Bitmap, rect: Rect) { +private fun blackout(bitmap: Bitmap, rect: Rect, useOvalMask: Boolean) { val safeRect = coerceRectToBitmap(rect, bitmap) val blackBitmap = createBitmap(safeRect.width(), safeRect.height()) @@ -81,13 +82,20 @@ private fun blackout(bitmap: Bitmap, rect: Rect) { val paint = Paint().apply { color = Color.BLACK } blackCanvas.drawRect(0f, 0f, safeRect.width().toFloat(), safeRect.height().toFloat(), paint) - val maskedBlack = applyOvalMask(blackBitmap, safeRect) + val finalBitmap = if (useOvalMask) applyOvalMask(blackBitmap, safeRect) else blackBitmap val canvas = Canvas(bitmap) - canvas.drawBitmap(maskedBlack, safeRect.left.toFloat(), safeRect.top.toFloat(), null) + canvas.drawBitmap(finalBitmap, safeRect.left.toFloat(), safeRect.top.toFloat(), null) } -private fun pixelate(bitmap: Bitmap, rect: Rect, region: Region, targetBlockSize: Int = 8, addNoise: Boolean = true) { +private fun pixelate( + bitmap: Bitmap, + rect: Rect, + region: Region, + targetBlockSize: Int = 8, + addNoise: Boolean = true, + useOvalMask: Boolean = true +) { val faceBitmap = Bitmap.createBitmap(bitmap, rect.left, rect.top, rect.width(), rect.height()) val small = faceBitmap.scale(targetBlockSize, targetBlockSize, false) @@ -164,13 +172,20 @@ private fun pixelate(bitmap: Bitmap, rect: Rect, region: Region, targetBlockSize } val pixelated = small.scale(rect.width(), rect.height(), false) - val maskedPixelated = applyOvalMask(pixelated, rect) + val finalBitmap = if (useOvalMask) applyOvalMask(pixelated, rect) else pixelated val canvas = Canvas(bitmap) - canvas.drawBitmap(maskedPixelated, rect.left.toFloat(), rect.top.toFloat(), null) + canvas.drawBitmap(finalBitmap, rect.left.toFloat(), rect.top.toFloat(), null) } -private fun blur(bitmap: Bitmap, rect: Rect, context: Context, radius: Float = 25f, rounds: Int = 10) { +private fun blur( + bitmap: Bitmap, + rect: Rect, + context: Context, + radius: Float = 25f, + rounds: Int = 10, + useOvalMask: Boolean = true +) { val safeRect = coerceRectToBitmap(rect, bitmap) val faceBitmap = Bitmap.createBitmap(bitmap, safeRect.left, safeRect.top, safeRect.width(), safeRect.height()) @@ -199,13 +214,13 @@ private fun blur(bitmap: Bitmap, rect: Rect, context: Context, radius: Float = 2 script.destroy() rs.destroy() - val maskedBlurred = applyOvalMask(faceBitmap, safeRect) + val finalBitmap = if (useOvalMask) applyOvalMask(faceBitmap, safeRect) else faceBitmap val canvas = Canvas(bitmap) - canvas.drawBitmap(maskedBlurred, safeRect.left.toFloat(), safeRect.top.toFloat(), null) + canvas.drawBitmap(finalBitmap, safeRect.left.toFloat(), safeRect.top.toFloat(), null) } -private fun noise(bitmap: Bitmap, rect: Rect) { +private fun noise(bitmap: Bitmap, rect: Rect, useOvalMask: Boolean) { val safeRect = coerceRectToBitmap(rect, bitmap) val noiseBitmap = createBitmap(safeRect.width(), safeRect.height()) @@ -220,8 +235,8 @@ private fun noise(bitmap: Bitmap, rect: Rect) { } } - val maskedNoise = applyOvalMask(noiseBitmap, safeRect) + val finalBitmap = if (useOvalMask) applyOvalMask(noiseBitmap, safeRect) else noiseBitmap val canvas = Canvas(bitmap) - canvas.drawBitmap(maskedNoise, safeRect.left.toFloat(), safeRect.top.toFloat(), null) + canvas.drawBitmap(finalBitmap, safeRect.left.toFloat(), safeRect.top.toFloat(), null) } From 17a9964c14e25493c995dccd34b2a21e08c4a157 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 16 Feb 2026 12:41:46 -0800 Subject: [PATCH 40/47] Fix the face oval being sideways --- .../app/securecamera/obfuscation/ObfuscationTools.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscationTools.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscationTools.kt index 300d799..db43bac 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscationTools.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscationTools.kt @@ -61,12 +61,14 @@ private fun applyOvalMask(sourceBitmap: Bitmap, rect: Rect): Bitmap { val paint = Paint(Paint.ANTI_ALIAS_FLAG) paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) - val ovalWidth = rect.width().toFloat() - val ovalHeight = ovalWidth / 1.6f + val scaleFactor = 1.2f + val ovalHeight = rect.height().toFloat() * scaleFactor + val ovalWidth = ovalHeight / 1.6f + val leftOffset = (rect.width() - ovalWidth) / 2f val topOffset = (rect.height() - ovalHeight) / 2f - val ovalRect = RectF(0f, topOffset.coerceAtLeast(0f), ovalWidth, topOffset.coerceAtLeast(0f) + ovalHeight) + val ovalRect = RectF(leftOffset, topOffset, leftOffset + ovalWidth, topOffset + ovalHeight) maskCanvas.drawOval(ovalRect, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE }) maskCanvas.drawBitmap(sourceBitmap, 0f, 0f, paint) From 9e8896ee500f61f91e47350de50ed8a2a8cf82e2 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 16 Feb 2026 13:04:28 -0800 Subject: [PATCH 41/47] Release prep --- fastlane/metadata/android/en-US/changelogs/30.txt | 3 +++ gradle/libs.versions.toml | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/30.txt diff --git a/fastlane/metadata/android/en-US/changelogs/30.txt b/fastlane/metadata/android/en-US/changelogs/30.txt new file mode 100644 index 0000000..cca3a0f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/30.txt @@ -0,0 +1,3 @@ +- Fix: Face blurring oval not covering all faces +- Fix: Poison Pill PIN now supports alphanumeric PINs +- Improved predictive back animations \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0e170d6..0a53428 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -versionCode = "29" -versionName = "4.1.1" +versionCode = "30" +versionName = "4.1.2" targetSdk = "36" compileSdk = "36" From 476a042ffe690764359846424b4c91e9b38268c0 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 22 Feb 2026 13:52:58 -0800 Subject: [PATCH 42/47] Update lib --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0a53428..a626aa8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,7 +34,7 @@ koin-bom = "4.1.1" cryptographyBom = "0.5.0" datastorePreferences = "1.2.0" timber = "5.0.1" -zoomable = "2.11.0" +zoomable = "2.11.1" media3 = "1.9.2" camerax = "1.5.3" foundation = "1.10.3" From 3f9b06b4885b48f95e69b4c81701c46a81d0cce6 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 22 Feb 2026 14:03:20 -0800 Subject: [PATCH 43/47] Make sure we clean up the session notification --- .../darkrockstudios/app/securecamera/auth/SessionService.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/SessionService.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/SessionService.kt index 99b1465..27af833 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/SessionService.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/SessionService.kt @@ -81,6 +81,7 @@ class SessionService : Service() { override fun onTaskRemoved(rootIntent: Intent?) { Timber.d("App task removed, clearing notification and stopping service") invalidateSessionUseCase.invalidateSession() + dismissNotification() stopSelf() super.onTaskRemoved(rootIntent) } @@ -92,6 +93,7 @@ class SessionService : Service() { if (!authRepository.checkSessionValidity()) { Timber.d("Session is no longer valid, invalidating session") invalidateSessionUseCase.invalidateSession() + dismissNotification() stopSelf() break } @@ -100,6 +102,7 @@ class SessionService : Service() { } catch (e: Exception) { Timber.e(e, "Error in SessionService") } finally { + dismissNotification() stopSelf() } } From 7542d56f6d2362ad3772cbad3b69ca424dd4ee80 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 22 Feb 2026 14:06:39 -0800 Subject: [PATCH 44/47] New Crowdin updates (#29) * New translations strings.xml (French) * New translations strings.xml (Spanish) * New translations strings.xml (German) * New translations strings.xml (Italian) * New translations strings.xml (Ukrainian) * New translations strings.xml (Chinese Simplified) * New translations strings.xml (English) * Update source file strings.xml * New translations strings.xml (French) * New translations strings.xml (Spanish) * New translations strings.xml (German) * New translations strings.xml (Italian) * New translations strings.xml (Ukrainian) * New translations strings.xml (Chinese Simplified) * New translations strings.xml (English) * Update source file strings.xml --- app/src/main/res/values-de-rDE/strings.xml | 10 ++++++++++ app/src/main/res/values-en-rUS/strings.xml | 10 ++++++++++ app/src/main/res/values-es-rES/strings.xml | 10 ++++++++++ app/src/main/res/values-fr-rFR/strings.xml | 10 ++++++++++ app/src/main/res/values-it-rIT/strings.xml | 10 ++++++++++ app/src/main/res/values-uk-rUA/strings.xml | 10 ++++++++++ app/src/main/res/values-zh-rCN/strings.xml | 10 ++++++++++ 7 files changed, 70 insertions(+) diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index cf06b2c..a822681 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -315,6 +315,12 @@ Foto Tresor… erstellen PIN anzeigen PIN ausblenden + + alpha-numerische PIN aktivieren + Erfahren Sie mehr über alpha-numerische PINs + Kurze PINs sind weniger sicher. Erwägen Sie 6+ Zeichen. + Alpha-numerische PINs + Alpha-numerische PINs erlauben Buchstaben (a-z, A-Z) neben Ziffern (0-9).\n\nDies erhöht die Sicherheit:\n\n- Eine 4-stellige numerische PIN hat 10, 00 mögliche Kombinationen\n- Eine vierstellige alpha-numerische PIN hat mehr als 14 Millionen Kombinationen\n\nDie Verwendung von Buchstaben und Zahlen erschwert deine PIN viel schwerer zu erraten oder zu erzwingen. Benachrichtigungen erlauben SnapSafe verwendet nur Benachrichtigungen, um die Kontrolle über im Hintergrund zu behalten, oder um Sie über den aktuellen Sicherheitsstatus zu informieren. Es wird dringend @@ -383,4 +389,8 @@ %1$d fehlgeschlagene Versuche werden zu einer vollständigen Datenlöschung führen.\nALLE PHOTOS WERDEN VERLUSS! + + Sicherheit wird aktualisiert + Migration %1$d von %2$d Dateien… + Bitte warten. Schließen Sie die App nicht. diff --git a/app/src/main/res/values-en-rUS/strings.xml b/app/src/main/res/values-en-rUS/strings.xml index 618cc72..9037c46 100644 --- a/app/src/main/res/values-en-rUS/strings.xml +++ b/app/src/main/res/values-en-rUS/strings.xml @@ -315,6 +315,12 @@ Creating photo vault… Show PIN Hide PIN + + Enable alpha-numeric PIN + Learn about alpha-numeric PINs + Short PINs are less secure. Consider using 6+ characters. + Alpha-numeric PINs + Alpha-numeric PINs allow letters (a-z, A-Z) in addition to digits (0-9).\n\nThis dramatically increases security:\n\n- A 4-digit numeric PIN has 10,000 possible combinations\n- A 4-character alpha-numeric PIN has over 14 million combinations\n\nUsing letters and numbers together makes your PIN much harder to guess or brute-force. Allow Notifications SnapSafe only uses notifications to keep you in control of work it may be doing in the background, or to inform you of the current security status. It is strongly @@ -383,4 +389,8 @@ %1$d failed attempts will result in a full data wipe.\nALL PHOTOS WILL BE LOST! + + Upgrading Security + Migrating %1$d of %2$d files… + Please wait. Do not close the app. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index a4356e0..5563b24 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -315,6 +315,12 @@ Creando bóveda de fotos… Mostrar PIN Ocultar PIN + + Habilitar PIN alfa numérico + Aprende sobre los PINs alfa numéricos + Los PINs cortos son menos seguros. Considere usar más de 6 caracteres. + PINs alfanuméricos + Los PIN alfa numérico permiten letras (a-z, A-Z) además de los dígitos (0-9).\n\nEsto aumenta dramáticamente la seguridad:\n\n- Un PIN numérico de 4 dígitos tiene 10, 00 posibles combinaciones\n- Un PIN alfanumérico de 4 caracteres tiene más de 14 millones de combinaciones\n\nEl uso de letras y números juntos hace que tu PIN sea mucho más difícil de adivinar o de fuerza bruta. Permitir notificaciones SnapSafe only uses notifications to keep you in control of work it may be doing in the background, or to inform you of the current security status. It is strongly @@ -383,4 +389,8 @@ %1$d failed attempts will result in a full data wipe.\nALL PHOTOS WILL BE LOST! + + Actualizando seguridad + Migrando %1$d de archivos %2$d… + Por favor espere. No cierre la aplicación. diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index eea4420..5028e3d 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -315,6 +315,12 @@ Création du coffre photo… Afficher le code PIN Masquer le code PIN + + Activer le code PIN alpha-numérique + En savoir plus sur les codes PIN alphanumériques + Les codes PIN courts sont moins sécurisés. Pensez à utiliser plus de 6 caractères. + Chiffres alphanumériques + Les codes PIN alphanumériques autorisent les lettres (a-z, A-Z) en plus des chiffres (0-9).\n\nCeci augmente considérablement la sécurité :\n\n- Un code PIN à 4 chiffres a 10, 00 combinaisons possibles\n- Un code PIN alpha-numérique de 4 caractères a plus de 14 millions de combinaisons\n\nL\'utilisation de lettres et de chiffres ensemble rend votre code PIN beaucoup plus difficile à deviner ou à forcer. Autoriser les notifications SnapSafe n\'utilise que les notifications pour vous garder en contrôle sur le travail qu\'il peut faire en arrière-plan, ou pour vous informer de l\'état actuel de la sécurité. Il est fortement @@ -383,4 +389,8 @@ %1$d failed attempts will result in a full data wipe.\nALL PHOTOS WILL BE LOST! + + Mise à niveau de la sécurité + Migration de %1$d des fichiers %2$d… + Veuillez patienter. Ne fermez pas l\'application. diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index d938f18..5ab38d9 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -315,6 +315,12 @@ Creazione cassaforte foto… Mostra PIN Nascondi PIN + + Abilita PIN alfanumerico + Informazioni sui PIN alfanumerici + I PIN corti sono meno sicuri. Considera di usare 6+ caratteri. + PIN Alfanumerici + I PIN alfanumerici consentono lettere (a-z, A-Z) in aggiunta alle cifre (0-9).\n\nQuesto aumenta notevolmente la sicurezza:\n\n- Un PIN numerico a 4 cifre ha 10, 00 possibili combinazioni\n- Un PIN alfanumerico a 4 caratteri ha oltre 14 milioni di combinazioni\n\nL\'utilizzo di lettere e numeri insieme rende il tuo PIN molto più difficile da indovinare o brute-forza. Consenti Notifiche SnapSafe utilizza solo le notifiche per mantenere il controllo di lavoro che può essere fatto in background, o per informarti dello stato di sicurezza attuale. È fortemente @@ -383,4 +389,8 @@ %1$d failed attempts will result in a full data wipe.\nALL PHOTOS WILL BE LOST! + + Aggiornamento Sicurezza + Migrating %1$d of %2$d files… + Attendere prego. Non chiudere l\'app. diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 4b33635..34c2850 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -315,6 +315,12 @@ Створення сховища фотографій… Показати PIN-код Сховати PIN-код + + Ввімкнути PIN-код алфавіту + Дізнатися про ідентифікатори для альфа-цифр + Короткі PIN-коди менш безпечні. Спробуйте використати 6+ символів. + Альфа-цифрові PIN-коди + Alpha-numeric PINs allow letters (a-z, A-Z) in addition to digits (0-9).\n\nThis dramatically increases security:\n\n- A 4-digit numeric PIN has 10,000 possible combinations\n- A 4-character alpha-numeric PIN has over 14 million combinations\n\nUsing letters and numbers together makes your PIN much harder to guess or brute-force. Дозволити сповіщення SnapSafe only uses notifications to keep you in control of work it may be doing in the background, or to inform you of the current security status. It is strongly @@ -383,4 +389,8 @@ %1$d failed attempts will result in a full data wipe.\nALL PHOTOS WILL BE LOST! + + Оновлення безпеки + Міграція %1$d з %2$d файлів… + Зачекайте, будь ласка. Не закривайте програму. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 28f077e..f128506 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -315,6 +315,12 @@ 创建照片保险库… 显示 PIN 码 隐藏 PIN + + 启用字母数字PIN + 学习字母数字PIN + 短的 PIN 不安全。请考虑使用 6 + 个字符。 + Alpha-numeric PIN + Alpha-numeric PINs allow letters (a-z, A-Z) in addition to digits (0-9).\n\nThis dramatically increases security:\n\n- A 4-digit numeric PIN has 10,000 possible combinations\n- A 4-character alpha-numeric PIN has over 14 million combinations\n\nUsing letters and numbers together makes your PIN much harder to guess or brute-force. 允许通知 SnapSafe 只能使用通知让您控制 在后台可能做的工作。 或通知您当前的安全状态。 强烈的 @@ -383,4 +389,8 @@ %1$d failed attempts will result in a full data wipe.\nALL PHOTOS WILL BE LOST! + + 升级安全 + Migrating %1$d of %2$d files… + 请稍候。请不要关闭应用程序。 From e48bd3888a0747fb63ac1976f0109c3eccd039e3 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 24 May 2026 15:59:13 -0800 Subject: [PATCH 45/47] Strip READ_PHONE_STATE from merged manifest (#35) A transitive dependency (likely ML Kit face detection / Play Services Basement in the full flavor) was injecting READ_PHONE_STATE into the merged manifest. The app doesn't need it, so remove it via the same tools:node="remove" pattern already used for INTERNET and ACCESS_NETWORK_STATE. Co-authored-by: Claude --- app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 62db951..be899be 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ + Date: Mon, 25 May 2026 00:52:25 -0700 Subject: [PATCH 46/47] Release prep --- 31.txt | 3 +++ gradle/libs.versions.toml | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 31.txt diff --git a/31.txt b/31.txt new file mode 100644 index 0000000..9c46b57 --- /dev/null +++ b/31.txt @@ -0,0 +1,3 @@ +- Localization updates +- Remove READ_PHONE_STATE permission +- Properly clear session notifications \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a626aa8..aed68de 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -versionCode = "30" -versionName = "4.1.2" +versionCode = "31" +versionName = "4.1.3" targetSdk = "36" compileSdk = "36" From 06d81b65ac029c221c634d2e691e6a7c9e173f0b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 25 May 2026 00:53:15 -0700 Subject: [PATCH 47/47] Upgrade libs --- gradle/libs.versions.toml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aed68de..34aba2f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,12 +7,12 @@ compileSdk = "36" minSdk = "29" javaVersion = "17" -agp = "8.12.3" -workManager = "2.11.1" +agp = "8.13.2" +workManager = "2.11.2" argon2kt = "1.6.0" bcrypt = "0.10.2" faceDetection = "16.1.7" -kotlinxCoroutinesTest = "1.10.2" +kotlinxCoroutinesTest = "1.11.0" rules = "1.7.0" accompanistPermissions = "0.37.3" coreSplashscreen = "1.2.0" @@ -20,24 +20,24 @@ lifecycleViewModel = "2.10.0" materialIconsExtended = "1.7.8" mockk = "1.14.9" kim = "0.26.2" -kotlin = "2.3.10" -kotlinx-serialization = "1.10.0" -runtimeLivedata = "1.10.3" -coreKtx = "1.17.0" +kotlin = "2.3.21" +kotlinx-serialization = "1.11.0" +runtimeLivedata = "1.11.2" +coreKtx = "1.18.0" junit = "4.13.2" junitVersion = "1.3.0" -activityCompose = "1.12.4" -composeBom = "2026.02.00" -navigation3 = "1.0.1" +activityCompose = "1.13.0" +composeBom = "2026.05.01" +navigation3 = "1.1.2" lifecycleViewmodelNav3 = "2.10.0" -koin-bom = "4.1.1" -cryptographyBom = "0.5.0" -datastorePreferences = "1.2.0" +koin-bom = "4.2.1" +cryptographyBom = "0.6.0" +datastorePreferences = "1.2.1" timber = "5.0.1" -zoomable = "2.11.1" -media3 = "1.9.2" -camerax = "1.5.3" -foundation = "1.10.3" +zoomable = "2.12.0" +media3 = "1.10.1" +camerax = "1.6.1" +foundation = "1.11.2" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }