From b4198847c304e1eb2f4275622a76ceb551c1f781 Mon Sep 17 00:00:00 2001 From: flohulo Date: Fri, 25 Sep 2026 17:29:35 +0200 Subject: [PATCH 01/17] init --- .../data/persistence/SettingsDao.kt | 19 ++- .../videodiary/data/persistence/VideosDao.kt | 50 +++++++- .../lukeneedham/videodiary/di/KoinModule.kt | 13 +- .../domain/model/VideoStorageLocation.kt | 7 + .../ui/feature/calendar/CalendarPage.kt | 8 +- .../feature/calendar/CalendarPageContent.kt | 102 +++++++++------ .../settings/component/SettingsCard.kt | 48 ++++++- .../ui/feature/storage/VideoStoragePage.kt | 25 ++++ .../storage/VideoStoragePageContent.kt | 121 ++++++++++++++++++ .../feature/storage/VideoStorageViewModel.kt | 52 ++++++++ .../ui/navigation/normal/NormalPage.kt | 1 + .../ui/navigation/normal/NormalRouter.kt | 111 +++++++++++++++- .../videodiary/ui/root/RootViewModel.kt | 6 +- app/src/main/res/xml/filepaths.xml | 3 +- 14 files changed, 501 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/com/lukeneedham/videodiary/domain/model/VideoStorageLocation.kt create mode 100644 app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt create mode 100644 app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt create mode 100644 app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt index 6969dbf3..5249640b 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt @@ -13,6 +13,7 @@ import androidx.datastore.preferences.preferencesDataStore import com.lukeneedham.videodiary.data.persistence.model.OrientationPersistence import com.lukeneedham.videodiary.domain.model.CameraResolutionRotation import com.lukeneedham.videodiary.domain.model.Orientation +import com.lukeneedham.videodiary.domain.model.VideoStorageLocation import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -40,6 +41,8 @@ class SettingsDao( /** Debug setting: whether the user is allowed to retake the video for a past day */ private val allowEditPastDays = booleanPreferencesKey("debugAllowRetakeForPastDays") + private val videoStorageLocationKey = stringPreferencesKey("videoStorageLocation") + suspend fun setResolution(resolution: Size) { val components = listOf(resolution.width, resolution.height) val string = components.joinToString(resolutionSeparator) @@ -112,6 +115,20 @@ class SettingsDao( } } + suspend fun setVideoStorageLocation(location: VideoStorageLocation) { + updatePrefs { + set(videoStorageLocationKey, location.name) + } + } + + fun getVideoStorageLocationFlow(): Flow { + return context.dataStore.data.map { prefs -> + prefs[videoStorageLocationKey] + ?.let { id -> VideoStorageLocation.entries.firstOrNull { it.name == id } } + ?: VideoStorageLocation.Internal + } + } + private suspend fun getPrefs() = context.dataStore.data.first() private suspend fun updatePrefs(block: MutablePreferences.() -> Unit) { @@ -125,4 +142,4 @@ class SettingsDao( companion object { private val resolutionSeparator = "x" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt index 7b29e555..a6e1f49f 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt @@ -2,23 +2,27 @@ package com.lukeneedham.videodiary.data.persistence import android.content.Context import android.net.Uri +import android.os.Environment import android.provider.MediaStore import com.lukeneedham.videodiary.R import com.lukeneedham.videodiary.data.mapper.ThumbnailFileNameMapper import com.lukeneedham.videodiary.data.mapper.VideoFileNameMapper import com.lukeneedham.videodiary.domain.util.logger.Logger +import com.lukeneedham.videodiary.domain.model.VideoStorageLocation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import java.io.File import java.time.LocalDate class VideosDao( private val context: Context, + private val settingsDao: SettingsDao, private val videoFileNameMapper: VideoFileNameMapper, private val thumbnailFileNameMapper: ThumbnailFileNameMapper, private val videoThumbnailExtractor: VideoThumbnailExtractor, ) { - private val videosDir = File(context.filesDir, "videos").apply { + private var videosDir = File(context.filesDir, "videos").apply { mkdirs() } @@ -27,9 +31,40 @@ class VideosDao( } private val allVideosMutable = MutableStateFlow(loadAllVideos()) + private val removableStorageAvailableMutable = MutableStateFlow(isRemovableStorageAvailable()) /** A Flow of all video files in the diary, unordered */ val allVideos = allVideosMutable.asStateFlow() + val removableStorageAvailable = removableStorageAvailableMutable.asStateFlow() + + suspend fun initialiseStorageLocation() { + val location = settingsDao.getVideoStorageLocationFlow().first() + videosDir = getVideosDir(location) ?: getVideosDir(VideoStorageLocation.Internal)!! + videosDir.mkdirs() + refreshVideosState() + } + + fun isRemovableStorageAvailable(): Boolean = getVideosDir(VideoStorageLocation.RemovableStorage) != null + + /** Moves all diary videos before changing the active storage location. */ + suspend fun moveVideosTo(location: VideoStorageLocation): Result = runCatching { + val destinationDir = getVideosDir(location) + ?: error("Removable storage is not available") + if (videosDir.canonicalPath == destinationDir.canonicalPath) return Result.success(Unit) + + destinationDir.mkdirs() + check(destinationDir.isDirectory) { "Could not create destination directory" } + val sourceFiles = videosDir.listFiles()?.toList().orEmpty() + sourceFiles.forEach { source -> + source.copyTo(File(destinationDir, source.name), overwrite = true) + } + sourceFiles.forEach { source -> + check(source.delete()) { "Could not remove ${source.name} from the old location" } + } + videosDir = destinationDir + removableStorageAvailableMutable.value = isRemovableStorageAvailable() + refreshVideosState() + } fun deleteVideo(date: LocalDate) { val file = getVideoFile(date) @@ -151,6 +186,17 @@ class VideosDao( private fun getVideoFile(name: String) = File(videosDir, name) + private fun getVideosDir(location: VideoStorageLocation): File? = when (location) { + VideoStorageLocation.Internal -> File(context.filesDir, "videos") + VideoStorageLocation.RemovableStorage -> context.getExternalFilesDirs(null) + .firstOrNull { directory -> + directory != null && + Environment.isExternalStorageRemovable(directory) && + Environment.getExternalStorageState(directory) == Environment.MEDIA_MOUNTED + } + ?.let { File(it, "videos") } + } + private fun getVideoFileName(date: LocalDate) = videoFileNameMapper.dateToName(date) private fun getThumbnailFile(date: LocalDate) = @@ -165,4 +211,4 @@ class VideosDao( val files = videosDir.listFiles() ?: return emptyList() return files.toList() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt b/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt index 138094aa..27023432 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt @@ -76,6 +76,7 @@ object KoinModule { single { VideosDao( context = androidContext(), + settingsDao = get(), videoFileNameMapper = get(), thumbnailFileNameMapper = get(), videoThumbnailExtractor = get(), @@ -182,14 +183,14 @@ object KoinModule { ) } viewModel { - RecapCreateCustomViewModel( - calendarRepository = get(), - savedRecapsDao = get(), + com.lukeneedham.videodiary.ui.feature.storage.VideoStorageViewModel( + settingsDao = get(), + videosDao = get(), + ioDispatcher = get(KoinQualifier.Dispatcher.io), ) } - viewModel { (periodType: RecapPeriodType) -> - RecapCreatePeriodListViewModel( - periodType = periodType, + viewModel { + ExportDiaryCreateViewModel( calendarRepository = get(), ) } diff --git a/app/src/main/java/com/lukeneedham/videodiary/domain/model/VideoStorageLocation.kt b/app/src/main/java/com/lukeneedham/videodiary/domain/model/VideoStorageLocation.kt new file mode 100644 index 00000000..bb127fbd --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/domain/model/VideoStorageLocation.kt @@ -0,0 +1,7 @@ +package com.lukeneedham.videodiary.domain.model + +/** Where diary video files are kept. */ +enum class VideoStorageLocation { + Internal, + RemovableStorage, +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/CalendarPage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/CalendarPage.kt index f6ae5694..623d1970 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/CalendarPage.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/CalendarPage.kt @@ -8,7 +8,9 @@ import java.time.LocalDate fun CalendarPage( viewModel: CalendarViewModel, onRecordVideoClick: (date: LocalDate) -> Unit, - onMenuClick: () -> Unit, + onExportClick: () -> Unit, + onStorageClick: () -> Unit, + onDebugClick: () -> Unit, share: (ShareRequest) -> Unit, ) { CalendarPageContent( @@ -21,6 +23,8 @@ fun CalendarPage( setCurrentDayIndex = viewModel::setCurrentDay, share = share, videoPlayerController = viewModel.videoPlayerController, - onMenuClick = onMenuClick, + onExportClick = onExportClick, + onStorageClick = onStorageClick, + onDebugClick = onDebugClick, ) } diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/CalendarPageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/CalendarPageContent.kt index 46c6978a..d4e46749 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/CalendarPageContent.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/CalendarPageContent.kt @@ -38,58 +38,76 @@ fun CalendarPageContent( setCurrentDayIndex: (Int) -> Unit, share: (ShareRequest) -> Unit, videoPlayerController: VideoPlayerController, - onMenuClick: () -> Unit, + onExportClick: () -> Unit, + onStorageClick: () -> Unit, + onDebugClick: () -> Unit, ) { val currentDay = days[currentDayIndex] var showDayPickerDialog by remember { mutableStateOf(false) } var pendingDateToDelete: LocalDate? by remember { mutableStateOf(null) } - // Pause the video while the date picker sheet is open, and resume it when it closes - val isDayPickerOpen = showDayPickerDialog - DisposableEffect(isDayPickerOpen) { - if (isDayPickerOpen) { - videoPlayerController.temporaryPause() - } - onDispose { - if (isDayPickerOpen) { - videoPlayerController.temporaryResume() - } - } + val drawerState = rememberDrawerState(DrawerValue.Closed) + val coroutineScope = rememberCoroutineScope() + + BackHandler(enabled = drawerState.isOpen) { + coroutineScope.launch { drawerState.close() } } - Box( - modifier = Modifier - .fillMaxSize() - .background(AppBackground) - ) { - if (days.isNotEmpty()) { - CalendarScroller( - days = days, - allowEditPastDays = allowEditPastDays, - onRecordVideoClick = onRecordVideoClick, - onDeleteVideoClick = { - pendingDateToDelete = it + ModalDrawer( + drawerState = drawerState, + drawerContent = { + CalendarSideMenu( + onExportClick = { + coroutineScope.launch { drawerState.close() } + onExportClick() }, - openDayPicker = { - showDayPickerDialog = true + onStorageClick = { + coroutineScope.launch { drawerState.close() } + onStorageClick() + }, + onDebugClick = { + coroutineScope.launch { drawerState.close() } + onDebugClick() }, - currentDayIndex = currentDayIndex, - setCurrentDayIndex = setCurrentDayIndex, - onMenuClick = onMenuClick, - share = share, - videoPlayerController = videoPlayerController, ) - } else { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() - ) { - CircularProgressIndicator( - modifier = Modifier.size(50.dp) + }, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(AppBackground) + ) { + if (days.isNotEmpty() && videoAspectRatio != null) { + CalendarScroller( + days = days, + videoAspectRatio = videoAspectRatio, + allowEditPastDays = allowEditPastDays, + onRecordVideoClick = onRecordVideoClick, + onDeleteVideoClick = { + pendingDateToDelete = it + }, + openDayPicker = { + showDayPickerDialog = true + }, + currentDayIndex = currentDayIndex, + setCurrentDayIndex = setCurrentDayIndex, + onMenuClick = { + coroutineScope.launch { drawerState.open() } + }, + share = share, + videoPlayerController = videoPlayerController, ) + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator( + modifier = Modifier.size(50.dp) + ) + } } - } if (showDayPickerDialog) { DiaryDatePickerSheet( @@ -137,7 +155,9 @@ private fun Preview( setCurrentDayIndex = {}, share = {}, videoPlayerController = rememberVideoPlayerController(), - onMenuClick = {}, + onExportClick = {}, + onStorageClick = {}, + onDebugClick = {}, ) } } @@ -152,4 +172,4 @@ private fun PreviewDay1() { @Composable private fun PreviewDay2() { Preview(currentDayIndex = 1) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/component/SettingsCard.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/component/SettingsCard.kt index 736ceb42..30569e11 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/component/SettingsCard.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/component/SettingsCard.kt @@ -26,8 +26,41 @@ import com.lukeneedham.videodiary.ui.theme.Typography /** A clickable settings card showing an icon and a name. */ @Composable -fun SettingsCard( - title: String, +fun CalendarSideMenu( + onExportClick: () -> Unit, + onStorageClick: () -> Unit, + onDebugClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxHeight() + .background(AppSurface) + .padding(vertical = 24.dp) + ) { + CalendarSideMenuItem( + iconRes = R.drawable.movie, + text = "Export", + onClick = onExportClick, + ) + CalendarSideMenuItem( + iconRes = R.drawable.movie, + text = "Video storage", + onClick = onStorageClick, + ) + + if (BuildConfig.DEBUG) { + CalendarSideMenuItem( + iconRes = R.drawable.bug, + text = "Debug", + onClick = onDebugClick, + ) + } + } +} + +@Composable +private fun CalendarSideMenuItem( iconRes: Int, onClick: () -> Unit, modifier: Modifier = Modifier, @@ -58,10 +91,11 @@ fun SettingsCard( @Preview @Composable -private fun PreviewSettingsCard() { - SettingsCard( - title = "Debug settings", - iconRes = R.drawable.bug, - onClick = {}, +private fun PreviewCalendarSideMenu() { + CalendarSideMenu( + onExportClick = {}, + onStorageClick = {}, + onDebugClick = {}, + modifier = Modifier.width(280.dp), ) } diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt new file mode 100644 index 00000000..42a1d848 --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt @@ -0,0 +1,25 @@ +package com.lukeneedham.videodiary.ui.feature.storage + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue + +@Composable +fun VideoStoragePage( + viewModel: VideoStorageViewModel, + canGoBack: Boolean, + onBack: () -> Unit, +) { + val location by viewModel.location.collectAsState() + val isRemovableStorageAvailable by viewModel.isRemovableStorageAvailable.collectAsState() + + VideoStoragePageContent( + location = location, + isRemovableStorageAvailable = isRemovableStorageAvailable, + isChangingLocation = viewModel.isChangingLocation, + error = viewModel.error, + onLocationSelected = viewModel::selectLocation, + canGoBack = canGoBack, + onBack = onBack, + ) +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt new file mode 100644 index 00000000..a5e9f79e --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt @@ -0,0 +1,121 @@ +package com.lukeneedham.videodiary.ui.feature.storage + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.RadioButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.lukeneedham.videodiary.domain.model.VideoStorageLocation +import com.lukeneedham.videodiary.ui.feature.common.toolbar.GenericToolbar +import com.lukeneedham.videodiary.ui.theme.Typography + +@Composable +fun VideoStoragePageContent( + location: VideoStorageLocation, + isRemovableStorageAvailable: Boolean, + isChangingLocation: Boolean, + error: String?, + onLocationSelected: (VideoStorageLocation) -> Unit, + canGoBack: Boolean, + onBack: () -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + GenericToolbar(canGoBack = canGoBack, onBack = onBack) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text(text = "Video storage", color = Color.Black, fontSize = Typography.Size.big) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Changing the location moves existing diary videos. Videos stored on removable storage are deleted if the app is uninstalled.", + color = Color.Black, + fontSize = Typography.Size.extraSmall, + ) + Spacer(modifier = Modifier.height(16.dp)) + StorageOption( + title = "Internal storage", + description = "Store diary videos in the app's internal storage.", + selected = location == VideoStorageLocation.Internal, + enabled = !isChangingLocation, + onClick = { onLocationSelected(VideoStorageLocation.Internal) }, + ) + StorageOption( + title = "SD card", + description = if (isRemovableStorageAvailable) { + "Store diary videos in this app's folder on the SD card." + } else { + "Insert a writable SD card to use this location." + }, + selected = location == VideoStorageLocation.RemovableStorage, + enabled = isRemovableStorageAvailable && !isChangingLocation, + onClick = { onLocationSelected(VideoStorageLocation.RemovableStorage) }, + ) + if (isChangingLocation) { + Spacer(modifier = Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.width(12.dp)) + Text(text = "Moving videos…", color = Color.Black) + } + } + if (error != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text(text = error, color = Color.Red, fontSize = Typography.Size.extraSmall) + } + } + } +} + +@Composable +private fun StorageOption( + title: String, + description: String, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(vertical = 10.dp), + ) { + RadioButton(selected = selected, onClick = onClick, enabled = enabled) + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text(text = title, color = if (enabled) Color.Black else Color.Gray, fontSize = Typography.Size.small) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = description, color = if (enabled) Color.Black else Color.Gray, fontSize = Typography.Size.extraSmall) + } + } +} + +@Preview +@Composable +private fun PreviewVideoStoragePageContent() { + VideoStoragePageContent( + location = VideoStorageLocation.Internal, + isRemovableStorageAvailable = true, + isChangingLocation = false, + error = null, + onLocationSelected = {}, + canGoBack = true, + onBack = {}, + ) +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt new file mode 100644 index 00000000..139caf1b --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt @@ -0,0 +1,52 @@ +package com.lukeneedham.videodiary.ui.feature.storage + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lukeneedham.videodiary.data.persistence.SettingsDao +import com.lukeneedham.videodiary.data.persistence.VideosDao +import com.lukeneedham.videodiary.domain.model.VideoStorageLocation +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class VideoStorageViewModel( + private val settingsDao: SettingsDao, + private val videosDao: VideosDao, + private val ioDispatcher: CoroutineDispatcher, +) : ViewModel() { + val location: StateFlow = settingsDao.getVideoStorageLocationFlow() + .stateIn(viewModelScope, SharingStarted.Eagerly, VideoStorageLocation.Internal) + + val isRemovableStorageAvailable: StateFlow = videosDao.removableStorageAvailable + .stateIn(viewModelScope, SharingStarted.Eagerly, videosDao.isRemovableStorageAvailable()) + + var isChangingLocation by mutableStateOf(false) + private set + + var error: String? by mutableStateOf(null) + private set + + fun selectLocation(location: VideoStorageLocation) { + if (isChangingLocation || location == this.location.value) return + + viewModelScope.launch { + isChangingLocation = true + error = null + val result = withContext(ioDispatcher) { + videosDao.moveVideosTo(location) + } + if (result.isSuccess) { + settingsDao.setVideoStorageLocation(location) + } else { + error = "Could not move videos. Your current storage location has not changed." + } + isChangingLocation = false + } + } +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalPage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalPage.kt index bcd69d1d..edbd27b1 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalPage.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalPage.kt @@ -26,6 +26,7 @@ sealed class NormalPage : Parcelable { ) : NormalPage() data object RecapHub : NormalPage() + data object VideoStorage : NormalPage() data class RecapCreatePeriodList(val periodType: RecapPeriodType) : NormalPage() data object RecapCreateCustom : NormalPage() data class RecapView( diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalRouter.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalRouter.kt index 92a2f189..223a5087 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalRouter.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalRouter.kt @@ -25,6 +25,7 @@ import com.lukeneedham.videodiary.ui.feature.recap.view.RecapViewPage import com.lukeneedham.videodiary.ui.feature.record.check.CheckVideoPage import com.lukeneedham.videodiary.ui.feature.record.film.RecordVideoPage import com.lukeneedham.videodiary.ui.feature.record.film.RecordVideoViewModel +import com.lukeneedham.videodiary.ui.feature.storage.VideoStoragePage import dev.olshevski.navigation.reimagined.NavBackHandler import dev.olshevski.navigation.reimagined.NavHost import dev.olshevski.navigation.reimagined.navigate @@ -95,10 +96,26 @@ fun NormalRouter( NavBackHandler(navController) - // Registered after the NavBackHandler so that it takes priority while the sheet is open - BackHandler(enabled = hubSheetState.isVisible) { - coroutineScope.launch { hubSheetState.hide() } - } + NavHost( + controller = navController, + ) { page -> + when (page) { + is NormalPage.Calendar -> CalendarPage( + viewModel = koinViewModel(), + onRecordVideoClick = { date -> + navigate(NormalPage.RecordVideo(date)) + }, + onExportClick = { + navigate(NormalPage.ExportHub) + }, + onStorageClick = { + navigate(NormalPage.VideoStorage) + }, + onDebugClick = { + navigate(NormalPage.Debug) + }, + share = share, + ) ModalBottomSheetLayout( sheetState = hubSheetState, @@ -258,6 +275,92 @@ fun NormalRouter( onBack = onBack, ) } + + is NormalPage.CheckVideo -> { + val returnToCalendar: () -> Unit = { + navController.popUpTo { it is NormalPage.Calendar } + } + CheckVideoPage( + viewModel = koinViewModel { + parametersOf(page.date, page.videoContentUri) + }, + onRetake = { + pop() + }, + onKeepExisting = returnToCalendar, + onKeepNew = returnToCalendar, + ) + } + + is NormalPage.ExportHub -> ExportHubPage( + viewModel = koinViewModel(), + canGoBack = canGoBack, + onBack = onBack, + onCreateExportClick = { + navigate(NormalPage.ExportDiaryCreate) + }, + onExportClick = { savedExport -> + val exportedVideo = ExportedVideo( + videoFile = savedExport.videoFile, + name = savedExport.name, + startDate = savedExport.startDate, + endDate = savedExport.endDate, + dayVideoCount = savedExport.dayVideoCount, + ) + navigate(NormalPage.ExportDiaryView(exportedVideo)) + }, + ) + + is NormalPage.VideoStorage -> VideoStoragePage( + viewModel = koinViewModel(), + canGoBack = canGoBack, + onBack = onBack, + ) + + is NormalPage.ExportDiaryCreate -> ExportDiaryCreatePage( + viewModel = koinViewModel(), + canGoBack = canGoBack, + onBack = onBack, + onExportRequested = { exportRequest -> + navigate(NormalPage.ExportDiaryProgress(exportRequest)) + } + ) + + is NormalPage.ExportDiaryProgress -> ExportDiaryProgressPage( + viewModel = koinViewModel { + parametersOf(page.exportRequest) + }, + onExported = { exportedVideo -> + navController.popUpTo { it is NormalPage.ExportDiaryCreate } + navigate(NormalPage.ExportDiaryView(exportedVideo)) + }, + onExit = { + pop() + }, + ) + + is NormalPage.ExportDiaryView -> ExportDiaryViewPage( + viewModel = koinViewModel(), + share = share, + canGoBack = canGoBack, + onBack = onBack, + exportedVideo = page.exportedVideo, + ) + + is NormalPage.Debug -> DebugPage( + viewModel = koinViewModel(), + canGoBack = canGoBack, + onBack = onBack, + onCrashLogClick = { + navigate(NormalPage.CrashLog) + }, + ) + + is NormalPage.CrashLog -> CrashLogPage( + viewModel = koinViewModel(), + canGoBack = canGoBack, + onBack = onBack, + ) } } } diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/root/RootViewModel.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/root/RootViewModel.kt index 2e75d0f2..de812f15 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/root/RootViewModel.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/root/RootViewModel.kt @@ -22,6 +22,7 @@ class RootViewModel( ) : ViewModel() { private var isMissingPermissions: Boolean? by mutableStateOf(null) private var hasSetupCompleted: Boolean? by mutableStateOf(null) + private var isStorageInitialised: Boolean by mutableStateOf(false) var orientationState: RootOrientationState by mutableStateOf(RootOrientationState.Loading) private set @@ -29,6 +30,7 @@ class RootViewModel( val state: RootState by derivedStateOf { val isMissingPermissions = isMissingPermissions ?: return@derivedStateOf RootState.Loading val hasSetupCompleted = hasSetupCompleted ?: return@derivedStateOf RootState.Loading + if (!isStorageInitialised) return@derivedStateOf RootState.Loading // First-time (or interrupted) setup always goes through the setup wizard, which shows the // onboarding intro before requesting permissions part-way through. @@ -65,8 +67,10 @@ class RootViewModel( } viewModelScope.launch { withContext(ioDispatcher) { + videosDao.initialiseStorageLocation() videosDao.generateMissingThumbnails() } + isStorageInitialised = true } } @@ -83,4 +87,4 @@ class RootViewModel( permissionChecker.hasPermission(permission.permission) } } -} \ No newline at end of file +} diff --git a/app/src/main/res/xml/filepaths.xml b/app/src/main/res/xml/filepaths.xml index 447a5024..a878666b 100644 --- a/app/src/main/res/xml/filepaths.xml +++ b/app/src/main/res/xml/filepaths.xml @@ -1,4 +1,5 @@ - \ No newline at end of file + + From cacf85e1416a885f770a9af2d2880262bac82d1c Mon Sep 17 00:00:00 2001 From: flohulo Date: Sun, 27 Sep 2026 16:19:21 +0200 Subject: [PATCH 02/17] fixes --- app/src/main/res/drawable/camera.xml | 2 +- app/src/main/res/drawable/mic.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/drawable/camera.xml b/app/src/main/res/drawable/camera.xml index c4cd0185..39ea02dd 100644 --- a/app/src/main/res/drawable/camera.xml +++ b/app/src/main/res/drawable/camera.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/mic.xml b/app/src/main/res/drawable/mic.xml index e13e0ea1..89a67565 100644 --- a/app/src/main/res/drawable/mic.xml +++ b/app/src/main/res/drawable/mic.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> From 5204d6d1f3c5de1dc39a5e62d395e4a69eba156d Mon Sep 17 00:00:00 2001 From: flohulo Date: Sun, 27 Sep 2026 16:33:28 +0200 Subject: [PATCH 03/17] possible to change folder --- .../data/persistence/SettingsDao.kt | 38 ++++++++++++++-- .../videodiary/data/persistence/VideosDao.kt | 43 +++++++++++++++++++ .../domain/model/VideoStorageLocation.kt | 7 +-- .../ui/feature/storage/VideoStoragePage.kt | 14 ++++++ .../storage/VideoStoragePageContent.kt | 15 +++++++ .../feature/storage/VideoStorageViewModel.kt | 34 +++++++++++++++ 6 files changed, 144 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt index 5249640b..ff688dbd 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt @@ -42,6 +42,8 @@ class SettingsDao( private val allowEditPastDays = booleanPreferencesKey("debugAllowRetakeForPastDays") private val videoStorageLocationKey = stringPreferencesKey("videoStorageLocation") + private val customVideoStorageUriKey = stringPreferencesKey("customVideoStorageUri") + private val customVideoStorageNameKey = stringPreferencesKey("customVideoStorageName") suspend fun setResolution(resolution: Size) { val components = listOf(resolution.width, resolution.height) @@ -117,15 +119,43 @@ class SettingsDao( suspend fun setVideoStorageLocation(location: VideoStorageLocation) { updatePrefs { - set(videoStorageLocationKey, location.name) + when (location) { + VideoStorageLocation.Internal -> { + set(videoStorageLocationKey, "Internal") + } + VideoStorageLocation.RemovableStorage -> { + set(videoStorageLocationKey, "RemovableStorage") + } + is VideoStorageLocation.Custom -> { + set(videoStorageLocationKey, "Custom") + set(customVideoStorageUriKey, location.uriString) + if (location.displayName != null) { + set(customVideoStorageNameKey, location.displayName) + } else { + remove(customVideoStorageNameKey) + } + } + } } } fun getVideoStorageLocationFlow(): Flow { return context.dataStore.data.map { prefs -> - prefs[videoStorageLocationKey] - ?.let { id -> VideoStorageLocation.entries.firstOrNull { it.name == id } } - ?: VideoStorageLocation.Internal + when (prefs[videoStorageLocationKey]) { + "RemovableStorage" -> VideoStorageLocation.RemovableStorage + "Custom" -> { + val uri = prefs[customVideoStorageUriKey] + if (uri != null) { + VideoStorageLocation.Custom( + uriString = uri, + displayName = prefs[customVideoStorageNameKey] + ) + } else { + VideoStorageLocation.Internal + } + } + else -> VideoStorageLocation.Internal + } } } diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt index a6e1f49f..d718d31b 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt @@ -195,6 +195,49 @@ class VideosDao( Environment.getExternalStorageState(directory) == Environment.MEDIA_MOUNTED } ?.let { File(it, "videos") } + is VideoStorageLocation.Custom -> { + val uri = Uri.parse(location.uriString) + getFileFromUri(context, uri) + } + } + + private fun getFileFromUri(context: Context, uri: Uri): File? { + if (uri.scheme == "file") { + return uri.path?.let { File(it) } + } + if (uri.scheme == "content") { + val docId = try { + android.provider.DocumentsContract.getTreeDocumentId(uri) + } catch (e: Exception) { + try { + android.provider.DocumentsContract.getDocumentId(uri) + } catch (e: Exception) { + null + } + } + if (docId != null) { + val split = docId.split(":") + val type = split[0] + val relativePath = if (split.size > 1) split[1] else "" + + if ("primary".equals(type, ignoreCase = true)) { + return File(Environment.getExternalStorageDirectory(), relativePath) + } else { + val externalDirs = context.getExternalFilesDirs(null) + for (dir in externalDirs) { + if (dir != null) { + val path = dir.absolutePath + val volumePath = path.substringBefore("/Android/data/") + if (volumePath.contains(type, ignoreCase = true)) { + return File(volumePath, relativePath) + } + } + } + return File("/storage/$type/$relativePath") + } + } + } + return null } private fun getVideoFileName(date: LocalDate) = videoFileNameMapper.dateToName(date) diff --git a/app/src/main/java/com/lukeneedham/videodiary/domain/model/VideoStorageLocation.kt b/app/src/main/java/com/lukeneedham/videodiary/domain/model/VideoStorageLocation.kt index bb127fbd..36f2b95d 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/domain/model/VideoStorageLocation.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/domain/model/VideoStorageLocation.kt @@ -1,7 +1,8 @@ package com.lukeneedham.videodiary.domain.model /** Where diary video files are kept. */ -enum class VideoStorageLocation { - Internal, - RemovableStorage, +sealed interface VideoStorageLocation { + data object Internal : VideoStorageLocation + data object RemovableStorage : VideoStorageLocation + data class Custom(val uriString: String, val displayName: String? = null) : VideoStorageLocation } diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt index 42a1d848..eb38c73c 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt @@ -1,8 +1,12 @@ package com.lukeneedham.videodiary.ui.feature.storage +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext @Composable fun VideoStoragePage( @@ -12,6 +16,15 @@ fun VideoStoragePage( ) { val location by viewModel.location.collectAsState() val isRemovableStorageAvailable by viewModel.isRemovableStorageAvailable.collectAsState() + val context = LocalContext.current + + val folderPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree() + ) { uri: Uri? -> + if (uri != null) { + viewModel.selectCustomFolder(context, uri) + } + } VideoStoragePageContent( location = location, @@ -19,6 +32,7 @@ fun VideoStoragePage( isChangingLocation = viewModel.isChangingLocation, error = viewModel.error, onLocationSelected = viewModel::selectLocation, + onSelectCustomFolder = { folderPickerLauncher.launch(null) }, canGoBack = canGoBack, onBack = onBack, ) diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt index a5e9f79e..140b1f02 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt @@ -29,6 +29,7 @@ fun VideoStoragePageContent( isChangingLocation: Boolean, error: String?, onLocationSelected: (VideoStorageLocation) -> Unit, + onSelectCustomFolder: () -> Unit, canGoBack: Boolean, onBack: () -> Unit, ) { @@ -65,6 +66,19 @@ fun VideoStoragePageContent( enabled = isRemovableStorageAvailable && !isChangingLocation, onClick = { onLocationSelected(VideoStorageLocation.RemovableStorage) }, ) + val isCustomSelected = location is VideoStorageLocation.Custom + val customDescription = if (location is VideoStorageLocation.Custom) { + "Current folder: ${location.displayName ?: location.uriString}" + } else { + "Select a custom folder on your device to store diary videos." + } + StorageOption( + title = "Custom folder", + description = customDescription, + selected = isCustomSelected, + enabled = !isChangingLocation, + onClick = onSelectCustomFolder, + ) if (isChangingLocation) { Spacer(modifier = Modifier.height(16.dp)) Row(verticalAlignment = Alignment.CenterVertically) { @@ -115,6 +129,7 @@ private fun PreviewVideoStoragePageContent() { isChangingLocation = false, error = null, onLocationSelected = {}, + onSelectCustomFolder = {}, canGoBack = true, onBack = {}, ) diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt index 139caf1b..a6f612e3 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt @@ -1,5 +1,9 @@ package com.lukeneedham.videodiary.ui.feature.storage +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -8,6 +12,7 @@ import androidx.lifecycle.viewModelScope import com.lukeneedham.videodiary.data.persistence.SettingsDao import com.lukeneedham.videodiary.data.persistence.VideosDao import com.lukeneedham.videodiary.domain.model.VideoStorageLocation +import com.lukeneedham.videodiary.domain.util.logger.Logger import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -32,6 +37,22 @@ class VideoStorageViewModel( var error: String? by mutableStateOf(null) private set + fun selectCustomFolder(context: Context, uri: Uri) { + val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + try { + context.contentResolver.takePersistableUriPermission(uri, takeFlags) + } catch (e: Exception) { + Logger.warning("Could not take persistable URI permission for custom folder", e) + } + + val displayName = getFolderDisplayName(uri) + val customLocation = VideoStorageLocation.Custom( + uriString = uri.toString(), + displayName = displayName, + ) + selectLocation(customLocation) + } + fun selectLocation(location: VideoStorageLocation) { if (isChangingLocation || location == this.location.value) return @@ -49,4 +70,17 @@ class VideoStorageViewModel( isChangingLocation = false } } + + private fun getFolderDisplayName(uri: Uri): String { + val docId = try { + DocumentsContract.getTreeDocumentId(uri) + } catch (e: Exception) { + null + } + if (docId != null) { + val folderName = docId.substringAfterLast(":", docId).substringAfterLast("/", docId) + if (folderName.isNotEmpty()) return folderName + } + return uri.lastPathSegment ?: "Custom Folder" + } } From 2aabfe524dd39cf4f44a0bf0e93cd3677e83547f Mon Sep 17 00:00:00 2001 From: flohulo Date: Sun, 27 Sep 2026 16:59:58 +0200 Subject: [PATCH 04/17] fix --- .../data/persistence/SettingsDao.kt | 22 ----- .../videodiary/data/persistence/VideosDao.kt | 90 ++++++++----------- .../domain/model/VideoStorageLocation.kt | 1 - .../ui/feature/storage/VideoStoragePage.kt | 15 ---- .../storage/VideoStoragePageContent.kt | 15 ---- .../feature/storage/VideoStorageViewModel.kt | 34 ------- 6 files changed, 39 insertions(+), 138 deletions(-) diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt index ff688dbd..8ecd907e 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt @@ -42,8 +42,6 @@ class SettingsDao( private val allowEditPastDays = booleanPreferencesKey("debugAllowRetakeForPastDays") private val videoStorageLocationKey = stringPreferencesKey("videoStorageLocation") - private val customVideoStorageUriKey = stringPreferencesKey("customVideoStorageUri") - private val customVideoStorageNameKey = stringPreferencesKey("customVideoStorageName") suspend fun setResolution(resolution: Size) { val components = listOf(resolution.width, resolution.height) @@ -126,15 +124,6 @@ class SettingsDao( VideoStorageLocation.RemovableStorage -> { set(videoStorageLocationKey, "RemovableStorage") } - is VideoStorageLocation.Custom -> { - set(videoStorageLocationKey, "Custom") - set(customVideoStorageUriKey, location.uriString) - if (location.displayName != null) { - set(customVideoStorageNameKey, location.displayName) - } else { - remove(customVideoStorageNameKey) - } - } } } } @@ -143,17 +132,6 @@ class SettingsDao( return context.dataStore.data.map { prefs -> when (prefs[videoStorageLocationKey]) { "RemovableStorage" -> VideoStorageLocation.RemovableStorage - "Custom" -> { - val uri = prefs[customVideoStorageUriKey] - if (uri != null) { - VideoStorageLocation.Custom( - uriString = uri, - displayName = prefs[customVideoStorageNameKey] - ) - } else { - VideoStorageLocation.Internal - } - } else -> VideoStorageLocation.Internal } } diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt index d718d31b..2afb3e5a 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt @@ -55,15 +55,46 @@ class VideosDao( destinationDir.mkdirs() check(destinationDir.isDirectory) { "Could not create destination directory" } val sourceFiles = videosDir.listFiles()?.toList().orEmpty() - sourceFiles.forEach { source -> - source.copyTo(File(destinationDir, source.name), overwrite = true) - } - sourceFiles.forEach { source -> - check(source.delete()) { "Could not remove ${source.name} from the old location" } + val temporaryFiles = mutableListOf() + val deletedSources = mutableListOf() + try { + sourceFiles.forEach { source -> + val destination = File(destinationDir, source.name) + check(!destination.exists()) { + "A video named ${source.name} already exists in the destination" + } + val temporary = File(destinationDir, ".${source.name}.moving") + check(!temporary.exists()) { + "A previous move for ${source.name} has not completed" + } + source.copyTo(temporary) + check(temporary.length() == source.length()) { + "Copied video ${source.name} has an unexpected size" + } + temporaryFiles += temporary + } + + sourceFiles.forEach { source -> + check(source.delete()) { "Could not remove ${source.name} from the old location" } + deletedSources += source + } + temporaryFiles.forEach { temporary -> + val destination = File(destinationDir, temporary.name.removePrefix(".").removeSuffix(".moving")) + check(temporary.renameTo(destination)) { "Could not finish moving ${destination.name}" } + } + videosDir = destinationDir + removableStorageAvailableMutable.value = isRemovableStorageAvailable() + refreshVideosState() + } catch (error: Exception) { + deletedSources.forEach { source -> + val temporary = temporaryFiles.firstOrNull { + it.name == ".${source.name}.moving" + } + temporary?.copyTo(source, overwrite = true) + } + temporaryFiles.forEach { it.delete() } + throw error } - videosDir = destinationDir - removableStorageAvailableMutable.value = isRemovableStorageAvailable() - refreshVideosState() } fun deleteVideo(date: LocalDate) { @@ -195,49 +226,6 @@ class VideosDao( Environment.getExternalStorageState(directory) == Environment.MEDIA_MOUNTED } ?.let { File(it, "videos") } - is VideoStorageLocation.Custom -> { - val uri = Uri.parse(location.uriString) - getFileFromUri(context, uri) - } - } - - private fun getFileFromUri(context: Context, uri: Uri): File? { - if (uri.scheme == "file") { - return uri.path?.let { File(it) } - } - if (uri.scheme == "content") { - val docId = try { - android.provider.DocumentsContract.getTreeDocumentId(uri) - } catch (e: Exception) { - try { - android.provider.DocumentsContract.getDocumentId(uri) - } catch (e: Exception) { - null - } - } - if (docId != null) { - val split = docId.split(":") - val type = split[0] - val relativePath = if (split.size > 1) split[1] else "" - - if ("primary".equals(type, ignoreCase = true)) { - return File(Environment.getExternalStorageDirectory(), relativePath) - } else { - val externalDirs = context.getExternalFilesDirs(null) - for (dir in externalDirs) { - if (dir != null) { - val path = dir.absolutePath - val volumePath = path.substringBefore("/Android/data/") - if (volumePath.contains(type, ignoreCase = true)) { - return File(volumePath, relativePath) - } - } - } - return File("/storage/$type/$relativePath") - } - } - } - return null } private fun getVideoFileName(date: LocalDate) = videoFileNameMapper.dateToName(date) diff --git a/app/src/main/java/com/lukeneedham/videodiary/domain/model/VideoStorageLocation.kt b/app/src/main/java/com/lukeneedham/videodiary/domain/model/VideoStorageLocation.kt index 36f2b95d..fa8274b1 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/domain/model/VideoStorageLocation.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/domain/model/VideoStorageLocation.kt @@ -4,5 +4,4 @@ package com.lukeneedham.videodiary.domain.model sealed interface VideoStorageLocation { data object Internal : VideoStorageLocation data object RemovableStorage : VideoStorageLocation - data class Custom(val uriString: String, val displayName: String? = null) : VideoStorageLocation } diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt index eb38c73c..de87ce91 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt @@ -1,12 +1,8 @@ package com.lukeneedham.videodiary.ui.feature.storage -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.LocalContext @Composable fun VideoStoragePage( @@ -16,23 +12,12 @@ fun VideoStoragePage( ) { val location by viewModel.location.collectAsState() val isRemovableStorageAvailable by viewModel.isRemovableStorageAvailable.collectAsState() - val context = LocalContext.current - - val folderPickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocumentTree() - ) { uri: Uri? -> - if (uri != null) { - viewModel.selectCustomFolder(context, uri) - } - } - VideoStoragePageContent( location = location, isRemovableStorageAvailable = isRemovableStorageAvailable, isChangingLocation = viewModel.isChangingLocation, error = viewModel.error, onLocationSelected = viewModel::selectLocation, - onSelectCustomFolder = { folderPickerLauncher.launch(null) }, canGoBack = canGoBack, onBack = onBack, ) diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt index 140b1f02..a5e9f79e 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt @@ -29,7 +29,6 @@ fun VideoStoragePageContent( isChangingLocation: Boolean, error: String?, onLocationSelected: (VideoStorageLocation) -> Unit, - onSelectCustomFolder: () -> Unit, canGoBack: Boolean, onBack: () -> Unit, ) { @@ -66,19 +65,6 @@ fun VideoStoragePageContent( enabled = isRemovableStorageAvailable && !isChangingLocation, onClick = { onLocationSelected(VideoStorageLocation.RemovableStorage) }, ) - val isCustomSelected = location is VideoStorageLocation.Custom - val customDescription = if (location is VideoStorageLocation.Custom) { - "Current folder: ${location.displayName ?: location.uriString}" - } else { - "Select a custom folder on your device to store diary videos." - } - StorageOption( - title = "Custom folder", - description = customDescription, - selected = isCustomSelected, - enabled = !isChangingLocation, - onClick = onSelectCustomFolder, - ) if (isChangingLocation) { Spacer(modifier = Modifier.height(16.dp)) Row(verticalAlignment = Alignment.CenterVertically) { @@ -129,7 +115,6 @@ private fun PreviewVideoStoragePageContent() { isChangingLocation = false, error = null, onLocationSelected = {}, - onSelectCustomFolder = {}, canGoBack = true, onBack = {}, ) diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt index a6f612e3..139caf1b 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt @@ -1,9 +1,5 @@ package com.lukeneedham.videodiary.ui.feature.storage -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.provider.DocumentsContract import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -12,7 +8,6 @@ import androidx.lifecycle.viewModelScope import com.lukeneedham.videodiary.data.persistence.SettingsDao import com.lukeneedham.videodiary.data.persistence.VideosDao import com.lukeneedham.videodiary.domain.model.VideoStorageLocation -import com.lukeneedham.videodiary.domain.util.logger.Logger import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -37,22 +32,6 @@ class VideoStorageViewModel( var error: String? by mutableStateOf(null) private set - fun selectCustomFolder(context: Context, uri: Uri) { - val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - try { - context.contentResolver.takePersistableUriPermission(uri, takeFlags) - } catch (e: Exception) { - Logger.warning("Could not take persistable URI permission for custom folder", e) - } - - val displayName = getFolderDisplayName(uri) - val customLocation = VideoStorageLocation.Custom( - uriString = uri.toString(), - displayName = displayName, - ) - selectLocation(customLocation) - } - fun selectLocation(location: VideoStorageLocation) { if (isChangingLocation || location == this.location.value) return @@ -70,17 +49,4 @@ class VideoStorageViewModel( isChangingLocation = false } } - - private fun getFolderDisplayName(uri: Uri): String { - val docId = try { - DocumentsContract.getTreeDocumentId(uri) - } catch (e: Exception) { - null - } - if (docId != null) { - val folderName = docId.substringAfterLast(":", docId).substringAfterLast("/", docId) - if (folderName.isNotEmpty()) return folderName - } - return uri.lastPathSegment ?: "Custom Folder" - } } From 91c8b62aee34b98a8beff4891f08eb85173bfcbe Mon Sep 17 00:00:00 2001 From: flohulo Date: Mon, 5 Oct 2026 22:54:20 +0200 Subject: [PATCH 05/17] fix --- .../lukeneedham/videodiary/di/KoinModule.kt | 8 +++ .../ui/feature/settings/SettingsHubPage.kt | 2 + .../settings/SettingsHubPageContent.kt | 7 +++ .../storage/VideoStoragePageContent.kt | 55 +++++++++++++++---- .../ui/navigation/normal/NormalRouter.kt | 10 ++++ 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt b/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt index 138094aa..9777f8b3 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt @@ -76,6 +76,7 @@ object KoinModule { single { VideosDao( context = androidContext(), + settingsDao = get(), videoFileNameMapper = get(), thumbnailFileNameMapper = get(), videoThumbnailExtractor = get(), @@ -181,6 +182,13 @@ object KoinModule { settingsDao = get(), ) } + viewModel { + com.lukeneedham.videodiary.ui.feature.storage.VideoStorageViewModel( + settingsDao = get(), + videosDao = get(), + ioDispatcher = get(KoinQualifier.Dispatcher.io), + ) + } viewModel { RecapCreateCustomViewModel( calendarRepository = get(), diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPage.kt index b34a9919..46c885e6 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPage.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPage.kt @@ -5,10 +5,12 @@ import androidx.compose.runtime.Composable @Composable fun SettingsHubPage( onMenuClick: () -> Unit, + onVideoStorageClick: () -> Unit, onDebugClick: () -> Unit, ) { SettingsHubPageContent( onMenuClick = onMenuClick, + onVideoStorageClick = onVideoStorageClick, onDebugClick = onDebugClick, ) } diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPageContent.kt index 7cdada6d..cfde4217 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPageContent.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPageContent.kt @@ -21,6 +21,7 @@ import com.lukeneedham.videodiary.ui.theme.AppBackground @Composable fun SettingsHubPageContent( onMenuClick: () -> Unit, + onVideoStorageClick: () -> Unit, onDebugClick: () -> Unit, ) { ToolbarPageLayout( @@ -34,6 +35,11 @@ fun SettingsHubPageContent( .verticalScroll(rememberScrollState()) .padding(16.dp) ) { + SettingsCard( + title = "Video storage", + iconRes = R.drawable.movie, + onClick = onVideoStorageClick, + ) if (BuildConfig.DEBUG) { SettingsCard( title = "Debug settings", @@ -50,6 +56,7 @@ fun SettingsHubPageContent( internal fun PreviewSettingsHubPageContent() { SettingsHubPageContent( onMenuClick = {}, + onVideoStorageClick = {}, onDebugClick = {}, ) } diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt index a5e9f79e..79dae00d 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt @@ -1,5 +1,6 @@ package com.lukeneedham.videodiary.ui.feature.storage +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,15 +12,20 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.RadioButton +import androidx.compose.material.RadioButtonDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.lukeneedham.videodiary.domain.model.VideoStorageLocation -import com.lukeneedham.videodiary.ui.feature.common.toolbar.GenericToolbar +import com.lukeneedham.videodiary.ui.feature.common.toolbar.SubpageToolbar +import com.lukeneedham.videodiary.ui.feature.common.toolbar.ToolbarPageLayout +import com.lukeneedham.videodiary.ui.theme.AccentHighlight +import com.lukeneedham.videodiary.ui.theme.AppBackground import com.lukeneedham.videodiary.ui.theme.Typography @Composable @@ -32,18 +38,25 @@ fun VideoStoragePageContent( canGoBack: Boolean, onBack: () -> Unit, ) { - Column(modifier = Modifier.fillMaxSize()) { - GenericToolbar(canGoBack = canGoBack, onBack = onBack) + ToolbarPageLayout( + bottomBar = { SubpageToolbar(canGoBack = canGoBack, onBack = onBack) }, + ) { Column( modifier = Modifier - .fillMaxWidth() + .fillMaxSize() + .background(AppBackground) .padding(20.dp) ) { - Text(text = "Video storage", color = Color.Black, fontSize = Typography.Size.big) + Text( + text = "Video storage", + color = Color.White, + fontSize = Typography.Size.big, + fontWeight = FontWeight.Bold, + ) Spacer(modifier = Modifier.height(12.dp)) Text( text = "Changing the location moves existing diary videos. Videos stored on removable storage are deleted if the app is uninstalled.", - color = Color.Black, + color = Color.White.copy(alpha = 0.6f), fontSize = Typography.Size.extraSmall, ) Spacer(modifier = Modifier.height(16.dp)) @@ -68,9 +81,12 @@ fun VideoStoragePageContent( if (isChangingLocation) { Spacer(modifier = Modifier.height(16.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.height(20.dp)) + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + color = AccentHighlight, + ) Spacer(modifier = Modifier.width(12.dp)) - Text(text = "Moving videos…", color = Color.Black) + Text(text = "Moving videos…", color = Color.White) } } if (error != null) { @@ -96,12 +112,29 @@ private fun StorageOption( .clickable(enabled = enabled, onClick = onClick) .padding(vertical = 10.dp), ) { - RadioButton(selected = selected, onClick = onClick, enabled = enabled) + RadioButton( + selected = selected, + onClick = onClick, + enabled = enabled, + colors = RadioButtonDefaults.colors( + selectedColor = AccentHighlight, + unselectedColor = Color.White.copy(alpha = 0.6f), + disabledColor = Color.White.copy(alpha = 0.2f), + ), + ) Spacer(modifier = Modifier.width(10.dp)) Column { - Text(text = title, color = if (enabled) Color.Black else Color.Gray, fontSize = Typography.Size.small) + Text( + text = title, + color = if (enabled) Color.White else Color.White.copy(alpha = 0.38f), + fontSize = Typography.Size.small, + ) Spacer(modifier = Modifier.height(4.dp)) - Text(text = description, color = if (enabled) Color.Black else Color.Gray, fontSize = Typography.Size.extraSmall) + Text( + text = description, + color = if (enabled) Color.White.copy(alpha = 0.6f) else Color.White.copy(alpha = 0.38f), + fontSize = Typography.Size.extraSmall, + ) } } } diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalRouter.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalRouter.kt index 92a2f189..90dcda3b 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalRouter.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalRouter.kt @@ -17,6 +17,7 @@ import com.lukeneedham.videodiary.ui.feature.common.sheet.SheetDefaults import com.lukeneedham.videodiary.ui.feature.crashlog.CrashLogPage import com.lukeneedham.videodiary.ui.feature.debug.DebugPage import com.lukeneedham.videodiary.ui.feature.settings.SettingsHubPage +import com.lukeneedham.videodiary.ui.feature.storage.VideoStoragePage import com.lukeneedham.videodiary.ui.feature.recap.create.RecapCreateCustomPage import com.lukeneedham.videodiary.ui.feature.recap.create.RecapCreatePeriodListPage import com.lukeneedham.videodiary.ui.feature.recap.hub.RecapHubPage @@ -238,11 +239,20 @@ fun NormalRouter( is NormalPage.SettingsHub -> SettingsHubPage( onMenuClick = openHubSwitcher, + onVideoStorageClick = { + navigate(NormalPage.VideoStorage) + }, onDebugClick = { navigate(NormalPage.Debug) }, ) + is NormalPage.VideoStorage -> VideoStoragePage( + viewModel = koinViewModel(), + canGoBack = canGoBack, + onBack = onBack, + ) + is NormalPage.Debug -> DebugPage( viewModel = koinViewModel(), canGoBack = canGoBack, From 4697076d476fa38641e4eb25db2b4626e164591c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:54:15 +0000 Subject: [PATCH 06/17] Make video storage migration crash-safe and recoverable - Copy videos via VideoFolderTransfer: sources are only deleted after every copy is verified, and failures roll back cleanly (no leftover temp files, no partial moves) - Save the storage setting inside the move and keep running if the caller is cancelled, so files and setting can't disagree - Fall back to internal storage when the SD card is missing, warn in the UI, and merge videos back when it returns - Re-check SD card availability when the storage screen opens - Guard file changes with a lock and detect diary changes during a move - Add unit tests; tidy Koin import and progress indicator size Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FBqFYw2zBG9C9sT7ABYdMN --- .../data/persistence/VideoFolderTransfer.kt | 106 +++++++++ .../videodiary/data/persistence/VideosDao.kt | 207 ++++++++++++------ .../lukeneedham/videodiary/di/KoinModule.kt | 3 +- .../ui/feature/storage/VideoStoragePage.kt | 2 + .../storage/VideoStoragePageContent.kt | 13 +- .../feature/storage/VideoStorageViewModel.kt | 17 +- .../persistence/VideoFolderTransferTest.kt | 128 +++++++++++ 7 files changed, 401 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransfer.kt create mode 100644 app/src/test/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransferTest.kt diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransfer.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransfer.kt new file mode 100644 index 00000000..7ac577b6 --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransfer.kt @@ -0,0 +1,106 @@ +package com.lukeneedham.videodiary.data.persistence + +import java.io.File + +/** + * A two-phase copy of every video in one folder into another, so that a failure at any point never + * loses a video: + * 1. [copy] copies each video into the destination (via a temporary file, so a half-written copy + * never has a real video name). The sources are not touched. + * 2. The caller then either [commit]s (deletes the sources) or [rollback]s (deletes the copies). + * + * Plain `java.io` only, so it can be unit tested on the JVM. + */ +class VideoFolderTransfer private constructor( + private val sources: List, + private val created: List, + private val sourceSnapshot: Map, + private val sourceDir: File, +) { + data class FileState(val length: Long, val lastModified: Long) + + /** True if the source folder is exactly as it was when it was copied. */ + fun isSourceUnchanged(): Boolean = snapshot(sourceDir) == sourceSnapshot + + /** Deletes the copies this transfer made. Videos which were already in the destination stay. */ + fun rollback() { + created.forEach { it.delete() } + } + + /** Deletes the sources. Returns the sources which could not be deleted. */ + fun commit(): List = sources.filterNot { it.delete() } + + companion object { + const val TEMPORARY_SUFFIX = ".moving" + + /** Deletes half-written copies left behind by a move that was interrupted */ + fun deleteTemporaryFiles(dir: File) { + dir.listFiles { file -> file.name.endsWith(TEMPORARY_SUFFIX) }?.forEach { it.delete() } + } + + fun snapshot(dir: File): Map { + return videoFiles(dir).associate { it.name to FileState(it.length(), it.lastModified()) } + } + + /** + * Copies every video in [from] to [to]. + * + * If [to] already holds a file with the same name and size, it is treated as an + * already-copied video. If it holds one with a different size, that is a conflict: + * with [failOnConflict] this throws, otherwise the existing file in [to] is kept + * and the one in [from] is left untouched. + * + * On any failure the copies made so far are removed and the sources are untouched. + */ + fun copy(from: File, to: File, failOnConflict: Boolean): VideoFolderTransfer { + to.mkdirs() + check(to.isDirectory) { "Could not create destination directory" } + deleteTemporaryFiles(to) + + val snapshot = snapshot(from) + val toMove = mutableListOf() + val created = mutableListOf() + try { + videoFiles(from).forEach { source -> + val destination = File(to, source.name) + if (destination.exists()) { + if (destination.length() == source.length()) { + toMove += source + } else { + check(!failOnConflict) { + "A different video named ${source.name} already exists in the destination" + } + } + return@forEach + } + + val temporary = File(to, ".${source.name}$TEMPORARY_SUFFIX") + try { + source.copyTo(temporary, overwrite = true) + check(temporary.length() == source.length()) { + "Copied video ${source.name} has an unexpected size" + } + check(temporary.renameTo(destination)) { + "Could not finish copying ${source.name}" + } + } finally { + // Does nothing once the rename succeeded + temporary.delete() + } + created += destination + toMove += source + } + } catch (error: Throwable) { + created.forEach { it.delete() } + throw error + } + return VideoFolderTransfer(toMove, created, snapshot, from) + } + + private fun videoFiles(dir: File): List { + return dir.listFiles { file -> + file.isFile && !file.name.endsWith(TEMPORARY_SUFFIX) + }?.toList().orEmpty() + } + } +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt index 38aef624..08f8b6f8 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt @@ -9,9 +9,11 @@ import com.lukeneedham.videodiary.data.mapper.ThumbnailFileNameMapper import com.lukeneedham.videodiary.data.mapper.VideoFileNameMapper import com.lukeneedham.videodiary.domain.util.logger.Logger import com.lukeneedham.videodiary.domain.model.VideoStorageLocation +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext import java.io.File import java.time.LocalDate @@ -22,85 +24,146 @@ class VideosDao( private val thumbnailFileNameMapper: ThumbnailFileNameMapper, private val videoThumbnailExtractor: VideoThumbnailExtractor, ) { - private var videosDir = File(context.filesDir, "videos").apply { + private val internalVideosDir = File(context.filesDir, "videos").apply { mkdirs() } + /** The folder videos are currently read from and written to */ + @Volatile + private var videosDir = internalVideosDir + + /** Held briefly by anything that changes the video files, and while a move is committed */ + private val filesLock = Any() + private val thumbnailsDir = File(context.filesDir, "thumbnails").apply { mkdirs() } private val allVideosMutable = MutableStateFlow(loadAllVideos()) private val removableStorageAvailableMutable = MutableStateFlow(isRemovableStorageAvailable()) + private val usingFallbackStorageMutable = MutableStateFlow(false) /** A Flow of all video files in the diary, unordered */ val allVideos = allVideosMutable.asStateFlow() + val removableStorageAvailable = removableStorageAvailableMutable.asStateFlow() + /** + * True when the chosen location is the SD card but it is not available right now, + * so internal storage is being used until it comes back. + */ + val usingFallbackStorage = usingFallbackStorageMutable.asStateFlow() + + /** + * Points the diary at the saved storage location. Also re-checks whether the SD card is + * available, so call it again when the SD card may have been inserted or removed. + * + * If the saved location is the SD card but it is missing, internal storage is used for now. + * Once the card is back, any videos recorded in the meantime are merged onto it. + */ suspend fun initialiseStorageLocation() { val location = settingsDao.getVideoStorageLocationFlow().first() - videosDir = getVideosDir(location) ?: getVideosDir(VideoStorageLocation.Internal)!! - videosDir.mkdirs() - refreshVideosState() - } + removableStorageAvailableMutable.value = isRemovableStorageAvailable() - fun isRemovableStorageAvailable(): Boolean = getVideosDir(VideoStorageLocation.RemovableStorage) != null + val wantedDir = getVideosDir(location) + if (wantedDir == null) { + usingFallbackStorageMutable.value = true + switchVideosDir(internalVideosDir) + return + } + usingFallbackStorageMutable.value = false - /** Moves all diary videos before changing the active storage location. */ - suspend fun moveVideosTo(location: VideoStorageLocation): Result = runCatching { - val destinationDir = getVideosDir(location) - ?: error("Removable storage is not available") - if (videosDir.canonicalPath == destinationDir.canonicalPath) return Result.success(Unit) + // Videos may be in the other folder after an interrupted move, or after a fallback + val otherDir = if (wantedDir == internalVideosDir) { + getVideosDir(VideoStorageLocation.RemovableStorage) + } else { + internalVideosDir + } + if (otherDir != null && otherDir != wantedDir) { + try { + mergeInto(wantedDir, from = otherDir) + } catch (e: Exception) { + Logger.warning("Could not merge videos into $wantedDir", e) + } + } + switchVideosDir(wantedDir) + } - destinationDir.mkdirs() - check(destinationDir.isDirectory) { "Could not create destination directory" } - val sourceFiles = videosDir.listFiles()?.toList().orEmpty() - val temporaryFiles = mutableListOf() - val deletedSources = mutableListOf() - try { - sourceFiles.forEach { source -> - val destination = File(destinationDir, source.name) - check(!destination.exists()) { - "A video named ${source.name} already exists in the destination" - } - val temporary = File(destinationDir, ".${source.name}.moving") - check(!temporary.exists()) { - "A previous move for ${source.name} has not completed" - } - source.copyTo(temporary) - check(temporary.length() == source.length()) { - "Copied video ${source.name} has an unexpected size" + fun isRemovableStorageAvailable(): Boolean = + getVideosDir(VideoStorageLocation.RemovableStorage) != null + + /** + * Moves all diary videos to [location] and saves it as the new storage location. + * On failure nothing changes: the videos stay where they were. + * + * This keeps going if the caller is cancelled, so a move is never left half-finished. + */ + suspend fun moveVideosTo(location: VideoStorageLocation): Result = + withContext(NonCancellable) { + runCatching { + val destinationDir = getVideosDir(location) + ?: error("Removable storage is not available") + val previousLocation = settingsDao.getVideoStorageLocationFlow().first() + val sourceDir = videosDir + if (sourceDir.canonicalPath == destinationDir.canonicalPath) { + settingsDao.setVideoStorageLocation(location) + usingFallbackStorageMutable.value = false + return@runCatching } - temporaryFiles += temporary - } - sourceFiles.forEach { source -> - check(source.delete()) { "Could not remove ${source.name} from the old location" } - deletedSources += source - } - temporaryFiles.forEach { temporary -> - val destination = File(destinationDir, temporary.name.removePrefix(".").removeSuffix(".moving")) - check(temporary.renameTo(destination)) { "Could not finish moving ${destination.name}" } - } - videosDir = destinationDir - removableStorageAvailableMutable.value = isRemovableStorageAvailable() - refreshVideosState() - } catch (error: Exception) { - deletedSources.forEach { source -> - val temporary = temporaryFiles.firstOrNull { - it.name == ".${source.name}.moving" + val transfer = VideoFolderTransfer.copy( + from = sourceDir, + to = destinationDir, + failOnConflict = true, + ) + try { + settingsDao.setVideoStorageLocation(location) + try { + synchronized(filesLock) { + // A video was recorded or deleted during the copy + check(transfer.isSourceUnchanged()) { + "The diary changed while videos were being moved" + } + videosDir = destinationDir + } + } catch (e: Throwable) { + settingsDao.setVideoStorageLocation(previousLocation) + throw e + } + } catch (e: Throwable) { + transfer.rollback() + throw e } - temporary?.copyTo(source, overwrite = true) - } - temporaryFiles.forEach { it.delete() } - throw error + + usingFallbackStorageMutable.value = false + refreshVideosState() + // The move already worked, so leftovers are just wasted space until the next merge + transfer.commit().forEach { Logger.warning("Could not delete old video $it") } + }.onFailure { Logger.warning("Failed to move videos to $location", it) } } + + /** Moves videos that are in [from] but not in [into], then deletes them from [from] */ + private fun mergeInto(into: File, from: File) { + VideoFolderTransfer.deleteTemporaryFiles(from) + if (from.listFiles().isNullOrEmpty()) return + val transfer = VideoFolderTransfer.copy(from = from, to = into, failOnConflict = false) + transfer.commit().forEach { Logger.warning("Could not delete merged video $it") } + } + + private fun switchVideosDir(dir: File) { + synchronized(filesLock) { + dir.mkdirs() + videosDir = dir + } + refreshVideosState() } fun deleteVideo(date: LocalDate) { - val file = getVideoFile(date) - file.delete() - getThumbnailFile(date).delete() + synchronized(filesLock) { + val file = getVideoFile(date) + file.delete() + getThumbnailFile(date).delete() + } refreshVideosState() } @@ -127,13 +190,15 @@ class VideosDao( val sourcePath = cursor.getString(columnIndex) val sourceFile = File(sourcePath) - val destinationFile = getVideoFile(date) - destinationFile.parentFile?.mkdirs() - if (destinationFile.exists()) { - destinationFile.delete() + val destinationFile = synchronized(filesLock) { + getVideoFile(date).also { destinationFile -> + destinationFile.parentFile?.mkdirs() + if (destinationFile.exists()) { + destinationFile.delete() + } + sourceFile.copyTo(destinationFile) + } } - - sourceFile.copyTo(destinationFile) videoThumbnailExtractor.extractFirstFrame(destinationFile, getThumbnailFile(date)) refreshVideosState() } else { @@ -151,14 +216,16 @@ class VideosDao( * the `sample_video` raw resource. Used to fill the diary with mock data for debugging. */ fun fillWithMockVideos(dates: List) { - videosDir.listFiles()?.forEach { it.delete() } - thumbnailsDir.listFiles()?.forEach { it.delete() } - dates.forEach { date -> - val videoFile = getVideoFile(date) - context.resources.openRawResource(R.raw.sample_video).use { input -> - videoFile.outputStream().use { output -> input.copyTo(output) } + synchronized(filesLock) { + videosDir.listFiles()?.forEach { it.delete() } + thumbnailsDir.listFiles()?.forEach { it.delete() } + dates.forEach { date -> + val videoFile = getVideoFile(date) + context.resources.openRawResource(R.raw.sample_video).use { input -> + videoFile.outputStream().use { output -> input.copyTo(output) } + } + videoThumbnailExtractor.extractFirstFrame(videoFile, getThumbnailFile(date)) } - videoThumbnailExtractor.extractFirstFrame(videoFile, getThumbnailFile(date)) } refreshVideosState() } @@ -193,7 +260,9 @@ class VideosDao( } private fun generateThumbnails(onlyMissing: Boolean) { - val videoFiles = videosDir.listFiles() ?: return + val videoFiles = videosDir.listFiles() + ?.filterNot { it.name.endsWith(VideoFolderTransfer.TEMPORARY_SUFFIX) } + ?: return var generatedAny = false videoFiles.forEach { videoFile -> val date = try { @@ -218,7 +287,7 @@ class VideosDao( private fun getVideoFile(name: String) = File(videosDir, name) private fun getVideosDir(location: VideoStorageLocation): File? = when (location) { - VideoStorageLocation.Internal -> File(context.filesDir, "videos") + VideoStorageLocation.Internal -> internalVideosDir VideoStorageLocation.RemovableStorage -> context.getExternalFilesDirs(null) .firstOrNull { directory -> directory != null && @@ -240,6 +309,6 @@ class VideosDao( private fun loadAllVideos(): List { val files = videosDir.listFiles() ?: return emptyList() - return files.toList() + return files.filterNot { it.name.endsWith(VideoFolderTransfer.TEMPORARY_SUFFIX) } } } \ No newline at end of file diff --git a/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt b/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt index 9777f8b3..dbff5526 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt @@ -22,6 +22,7 @@ import com.lukeneedham.videodiary.ui.feature.crashlog.CrashLogViewModel import com.lukeneedham.videodiary.ui.feature.debug.DebugViewModel import com.lukeneedham.videodiary.ui.feature.permissions.RequestPermissionsViewModel import com.lukeneedham.videodiary.ui.feature.recap.create.RecapCreateCustomViewModel +import com.lukeneedham.videodiary.ui.feature.storage.VideoStorageViewModel import com.lukeneedham.videodiary.ui.feature.recap.create.RecapCreatePeriodListViewModel import com.lukeneedham.videodiary.ui.feature.recap.hub.RecapHubViewModel import com.lukeneedham.videodiary.ui.feature.recap.model.RecapPeriodType @@ -183,7 +184,7 @@ object KoinModule { ) } viewModel { - com.lukeneedham.videodiary.ui.feature.storage.VideoStorageViewModel( + VideoStorageViewModel( settingsDao = get(), videosDao = get(), ioDispatcher = get(KoinQualifier.Dispatcher.io), diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt index de87ce91..8caaabac 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt @@ -12,9 +12,11 @@ fun VideoStoragePage( ) { val location by viewModel.location.collectAsState() val isRemovableStorageAvailable by viewModel.isRemovableStorageAvailable.collectAsState() + val isUsingFallbackStorage by viewModel.isUsingFallbackStorage.collectAsState() VideoStoragePageContent( location = location, isRemovableStorageAvailable = isRemovableStorageAvailable, + isUsingFallbackStorage = isUsingFallbackStorage, isChangingLocation = viewModel.isChangingLocation, error = viewModel.error, onLocationSelected = viewModel::selectLocation, diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt index 79dae00d..49475956 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.RadioButton @@ -32,6 +33,7 @@ import com.lukeneedham.videodiary.ui.theme.Typography fun VideoStoragePageContent( location: VideoStorageLocation, isRemovableStorageAvailable: Boolean, + isUsingFallbackStorage: Boolean, isChangingLocation: Boolean, error: String?, onLocationSelected: (VideoStorageLocation) -> Unit, @@ -78,11 +80,19 @@ fun VideoStoragePageContent( enabled = isRemovableStorageAvailable && !isChangingLocation, onClick = { onLocationSelected(VideoStorageLocation.RemovableStorage) }, ) + if (isUsingFallbackStorage) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "The SD card is not available, so videos are temporarily being kept in internal storage. They will be moved back to the SD card when it is inserted.", + color = Color.Yellow, + fontSize = Typography.Size.extraSmall, + ) + } if (isChangingLocation) { Spacer(modifier = Modifier.height(16.dp)) Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator( - modifier = Modifier.height(20.dp), + modifier = Modifier.size(20.dp), color = AccentHighlight, ) Spacer(modifier = Modifier.width(12.dp)) @@ -145,6 +155,7 @@ private fun PreviewVideoStoragePageContent() { VideoStoragePageContent( location = VideoStorageLocation.Internal, isRemovableStorageAvailable = true, + isUsingFallbackStorage = false, isChangingLocation = false, error = null, onLocationSelected = {}, diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt index 139caf1b..cfc34001 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class VideoStorageViewModel( - private val settingsDao: SettingsDao, + settingsDao: SettingsDao, private val videosDao: VideosDao, private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { @@ -26,12 +26,23 @@ class VideoStorageViewModel( val isRemovableStorageAvailable: StateFlow = videosDao.removableStorageAvailable .stateIn(viewModelScope, SharingStarted.Eagerly, videosDao.isRemovableStorageAvailable()) + val isUsingFallbackStorage: StateFlow = videosDao.usingFallbackStorage + var isChangingLocation by mutableStateOf(false) private set var error: String? by mutableStateOf(null) private set + init { + // The SD card may have been inserted or removed since the app started + viewModelScope.launch { + withContext(ioDispatcher) { + videosDao.initialiseStorageLocation() + } + } + } + fun selectLocation(location: VideoStorageLocation) { if (isChangingLocation || location == this.location.value) return @@ -41,9 +52,7 @@ class VideoStorageViewModel( val result = withContext(ioDispatcher) { videosDao.moveVideosTo(location) } - if (result.isSuccess) { - settingsDao.setVideoStorageLocation(location) - } else { + if (result.isFailure) { error = "Could not move videos. Your current storage location has not changed." } isChangingLocation = false diff --git a/app/src/test/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransferTest.kt b/app/src/test/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransferTest.kt new file mode 100644 index 00000000..9039602f --- /dev/null +++ b/app/src/test/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransferTest.kt @@ -0,0 +1,128 @@ +package com.lukeneedham.videodiary.data.persistence + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class VideoFolderTransferTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var from: File + private lateinit var to: File + + @Before + fun setUp() { + from = tempFolder.newFolder("from") + to = File(tempFolder.root, "to") + } + + private fun File.write(name: String, text: String) = File(this, name).apply { writeText(text) } + + @Test + fun copyThenCommitMovesEveryVideo() { + from.write("a", "aaa") + from.write("b", "bb") + + val transfer = VideoFolderTransfer.copy(from, to, failOnConflict = true) + assertEquals("aaa", File(to, "a").readText()) + assertTrue("Sources are untouched until commit", File(from, "a").exists()) + + assertTrue(transfer.commit().isEmpty()) + assertEquals(emptyList(), from.list()!!.toList()) + assertEquals(listOf("a", "b"), to.list()!!.sorted()) + } + + @Test + fun rollbackRemovesCopiesAndKeepsSources() { + from.write("a", "aaa") + + VideoFolderTransfer.copy(from, to, failOnConflict = true).rollback() + + assertEquals("aaa", File(from, "a").readText()) + assertEquals(emptyList(), to.list()!!.toList()) + } + + @Test + fun conflictFailsAndLeavesNothingBehind() { + from.write("a", "aaa") + from.write("b", "bbb") + to.mkdirs() + to.write("b", "different length") + + try { + VideoFolderTransfer.copy(from, to, failOnConflict = true) + fail("Expected a conflict") + } catch (e: IllegalStateException) { + // expected + } + + assertEquals(listOf("a", "b"), from.list()!!.sorted()) + // Only the destination's own file remains: no partial copy of "a", no temporary files + assertEquals(listOf("b"), to.list()!!.toList()) + assertEquals("different length", File(to, "b").readText()) + } + + @Test + fun conflictIsKeptAsIsWhenNotFailing() { + from.write("a", "aaa") + from.write("b", "bbb") + to.mkdirs() + to.write("b", "different length") + + val transfer = VideoFolderTransfer.copy(from, to, failOnConflict = false) + transfer.commit() + + assertEquals("different length", File(to, "b").readText()) + assertEquals("aaa", File(to, "a").readText()) + assertEquals("The conflicting source must not be deleted", listOf("b"), from.list()!!.toList()) + } + + @Test + fun alreadyCopiedVideoIsNotAConflictAndIsNotRolledBack() { + from.write("a", "aaa") + to.mkdirs() + to.write("a", "xyz") + + val transfer = VideoFolderTransfer.copy(from, to, failOnConflict = true) + transfer.rollback() + + assertTrue("Rollback must not delete a file it did not create", File(to, "a").exists()) + } + + @Test + fun staleTemporaryFilesDoNotBlockAndAreCleanedUp() { + from.write("a", "aaa") + to.mkdirs() + to.write(".a${VideoFolderTransfer.TEMPORARY_SUFFIX}", "half") + + VideoFolderTransfer.copy(from, to, failOnConflict = true) + + assertEquals(listOf("a"), to.list()!!.toList()) + } + + @Test + fun temporaryFilesInSourceAreNotMoved() { + from.write(".a${VideoFolderTransfer.TEMPORARY_SUFFIX}", "half") + + VideoFolderTransfer.copy(from, to, failOnConflict = true).commit() + + assertEquals(emptyList(), to.list()!!.toList()) + } + + @Test + fun detectsSourceChangedAfterCopy() { + from.write("a", "aaa") + val transfer = VideoFolderTransfer.copy(from, to, failOnConflict = true) + assertTrue(transfer.isSourceUnchanged()) + + from.write("new", "recorded during the copy") + assertFalse(transfer.isSourceUnchanged()) + } +} From fb19bbb7f0d25a0406d7c6a48e7c2118849cbed8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:43:15 +0000 Subject: [PATCH 07/17] Restyle video storage page to match the Debug page Uses the same header, uppercase section label, rounded AppSurface card with icon-box rows and dividers, accent-tinted notices, and radio buttons coloured like the Debug switches. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FBqFYw2zBG9C9sT7ABYdMN --- .../storage/VideoStoragePageContent.kt | 233 ++++++++++++++---- 1 file changed, 181 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt index 49475956..2c13b97e 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt @@ -1,7 +1,9 @@ package com.lukeneedham.videodiary.ui.feature.storage +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -11,6 +13,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.RadioButton import androidx.compose.material.RadioButtonDefaults @@ -18,15 +23,23 @@ import androidx.compose.material.Text 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.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lukeneedham.videodiary.R import com.lukeneedham.videodiary.domain.model.VideoStorageLocation import com.lukeneedham.videodiary.ui.feature.common.toolbar.SubpageToolbar import com.lukeneedham.videodiary.ui.feature.common.toolbar.ToolbarPageLayout +import com.lukeneedham.videodiary.ui.theme.AccentAccept import com.lukeneedham.videodiary.ui.theme.AccentHighlight +import com.lukeneedham.videodiary.ui.theme.AccentRecord import com.lukeneedham.videodiary.ui.theme.AppBackground +import com.lukeneedham.videodiary.ui.theme.AppSurface import com.lukeneedham.videodiary.ui.theme.Typography @Composable @@ -47,7 +60,8 @@ fun VideoStoragePageContent( modifier = Modifier .fillMaxSize() .background(AppBackground) - .padding(20.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 24.dp), ) { Text( text = "Video storage", @@ -55,97 +69,197 @@ fun VideoStoragePageContent( fontSize = Typography.Size.big, fontWeight = FontWeight.Bold, ) - Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Changing the location moves existing diary videos. Videos stored on removable storage are deleted if the app is uninstalled.", + text = "Changing the location moves your existing diary videos", color = Color.White.copy(alpha = 0.6f), fontSize = Typography.Size.extraSmall, + modifier = Modifier.padding(top = 4.dp), ) - Spacer(modifier = Modifier.height(16.dp)) - StorageOption( - title = "Internal storage", - description = "Store diary videos in the app's internal storage.", - selected = location == VideoStorageLocation.Internal, - enabled = !isChangingLocation, - onClick = { onLocationSelected(VideoStorageLocation.Internal) }, - ) - StorageOption( - title = "SD card", - description = if (isRemovableStorageAvailable) { - "Store diary videos in this app's folder on the SD card." - } else { - "Insert a writable SD card to use this location." - }, - selected = location == VideoStorageLocation.RemovableStorage, - enabled = isRemovableStorageAvailable && !isChangingLocation, - onClick = { onLocationSelected(VideoStorageLocation.RemovableStorage) }, - ) - if (isUsingFallbackStorage) { - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "The SD card is not available, so videos are temporarily being kept in internal storage. They will be moved back to the SD card when it is inserted.", - color = Color.Yellow, - fontSize = Typography.Size.extraSmall, + + Section(title = "Location") { + StorageOptionRow( + iconRes = R.drawable.movie, + accent = AccentAccept, + title = "Internal storage", + description = "Keep diary videos in the app's internal storage", + selected = location == VideoStorageLocation.Internal, + enabled = !isChangingLocation, + onClick = { onLocationSelected(VideoStorageLocation.Internal) }, + ) + Divider() + StorageOptionRow( + iconRes = R.drawable.movie, + accent = AccentAccept, + title = "SD card", + description = if (isRemovableStorageAvailable) { + "Keep diary videos in this app's folder on the SD card. " + + "They are deleted if the app is uninstalled" + } else { + "Insert a writable SD card to use this location" + }, + selected = location == VideoStorageLocation.RemovableStorage, + enabled = isRemovableStorageAvailable && !isChangingLocation, + onClick = { onLocationSelected(VideoStorageLocation.RemovableStorage) }, ) } + if (isChangingLocation) { - Spacer(modifier = Modifier.height(16.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 24.dp, start = 4.dp), + ) { CircularProgressIndicator( - modifier = Modifier.size(20.dp), color = AccentHighlight, + strokeWidth = 2.dp, + modifier = Modifier.size(20.dp), ) Spacer(modifier = Modifier.width(12.dp)) - Text(text = "Moving videos…", color = Color.White) + Text( + text = "Moving videos…", + color = Color.White, + fontSize = Typography.Size.medium, + ) } } + if (isUsingFallbackStorage) { + Notice( + accent = AccentHighlight, + text = "The SD card is not available, so videos are temporarily being kept " + + "in internal storage. They will be moved back to the SD card when it " + + "is inserted", + ) + } if (error != null) { - Spacer(modifier = Modifier.height(16.dp)) - Text(text = error, color = Color.Red, fontSize = Typography.Size.extraSmall) + Notice(accent = AccentRecord, text = error) } } } } +/** A titled group of rows, shown together in one rounded card. */ @Composable -private fun StorageOption( +private fun Section( + title: String, + content: @Composable () -> Unit, +) { + Text( + text = title.uppercase(), + color = Color.White.copy(alpha = 0.5f), + fontSize = Typography.Size.extraSmall, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.sp, + modifier = Modifier.padding(top = 28.dp, bottom = 8.dp, start = 4.dp), + ) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(AppSurface) + ) { + content() + } +} + +@Composable +private fun Divider() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 72.dp) + .height(1.dp) + .background(Color.White.copy(alpha = 0.08f)) + ) +} + +@Composable +private fun StorageOptionRow( + iconRes: Int, + accent: Color, title: String, description: String, selected: Boolean, enabled: Boolean, onClick: () -> Unit, ) { + val contentAlpha = if (enabled) 1f else 0.4f Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable(enabled = enabled, onClick = onClick) - .padding(vertical = 10.dp), + .padding(16.dp) ) { - RadioButton( - selected = selected, - onClick = onClick, - enabled = enabled, - colors = RadioButtonDefaults.colors( - selectedColor = AccentHighlight, - unselectedColor = Color.White.copy(alpha = 0.6f), - disabledColor = Color.White.copy(alpha = 0.2f), - ), - ) - Spacer(modifier = Modifier.width(10.dp)) - Column { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(10.dp)) + .background(accent.copy(alpha = 0.15f * contentAlpha)) + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + colorFilter = ColorFilter.tint(accent.copy(alpha = contentAlpha)), + modifier = Modifier.size(22.dp), + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { Text( text = title, - color = if (enabled) Color.White else Color.White.copy(alpha = 0.38f), - fontSize = Typography.Size.small, + color = Color.White.copy(alpha = contentAlpha), + fontSize = Typography.Size.medium, + fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(2.dp)) Text( text = description, - color = if (enabled) Color.White.copy(alpha = 0.6f) else Color.White.copy(alpha = 0.38f), + color = Color.White.copy(alpha = 0.6f * contentAlpha), fontSize = Typography.Size.extraSmall, ) } + Spacer(modifier = Modifier.width(8.dp)) + RadioButton( + selected = selected, + onClick = onClick, + enabled = enabled, + colors = RadioButtonDefaults.colors( + selectedColor = AccentAccept, + unselectedColor = Color.White.copy(alpha = 0.4f), + disabledColor = Color.White.copy(alpha = 0.2f), + ), + ) + } +} + +/** A message in a rounded card, tinted with [accent] like the icon boxes. */ +@Composable +private fun Notice( + accent: Color, + text: String, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(top = 24.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(accent.copy(alpha = 0.15f)) + .padding(16.dp) + ) { + Image( + painter = painterResource(R.drawable.info), + contentDescription = null, + colorFilter = ColorFilter.tint(accent), + modifier = Modifier.size(22.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = text, + color = Color.White, + fontSize = Typography.Size.extraSmall, + modifier = Modifier.weight(1f), + ) } } @@ -163,3 +277,18 @@ private fun PreviewVideoStoragePageContent() { onBack = {}, ) } + +@Preview +@Composable +private fun PreviewVideoStoragePageContentFallbackAndError() { + VideoStoragePageContent( + location = VideoStorageLocation.RemovableStorage, + isRemovableStorageAvailable = false, + isUsingFallbackStorage = true, + isChangingLocation = false, + error = "Could not move videos. Your current storage location has not changed.", + onLocationSelected = {}, + canGoBack = true, + onBack = {}, + ) +} From a503f17a0fcaa2ac8a37d82c71d8d5c59eebe469 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 21:00:37 +0000 Subject: [PATCH 08/17] Add debug-only fake SD card and slow-move switches The Debug page gets a Storage section: a fake SD card (a folder in the app's files), a switch to insert/remove it, and an 8 second pause during video moves, so Video storage can be tested without a real SD card. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FBqFYw2zBG9C9sT7ABYdMN --- CLAUDE.md | 3 ++ .../data/persistence/SettingsDao.kt | 33 ++++++++++++++ .../videodiary/data/persistence/VideosDao.kt | 34 +++++++++++++- .../videodiary/ui/feature/debug/DebugPage.kt | 10 +++++ .../ui/feature/debug/DebugPageContent.kt | 45 +++++++++++++++++++ .../ui/feature/debug/DebugViewModel.kt | 31 +++++++++++++ 6 files changed, 154 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 262527de..41ec2363 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,6 +156,9 @@ docs: `docs/using-the-library.md` there). The app has no logger of its own. `ProvideDebugSettings` is in `MainActivity`): the recap share sheet settle (`RecapViewPageContent`) and the camera focus indicator hold and fade (`CameraInput`). Give any new animation an explicit duration and pass it through `scale`. Animations that use the default spring (`SingleOptionSelector`, the orientation text colour) are not affected by the setting. + The Debug page's Storage section fakes an SD card for testing Video storage on a phone without one: "Fake SD card" swaps the removable + storage folder for `filesDir/fake_sd_card/videos`, "Fake SD card inserted" simulates removing and re-inserting it, and "Slow video moves" adds + an 8 second pause to a move so it can be interrupted (all `BuildConfig.DEBUG` only, applied in `VideosDao`). Mock entries are populated by copying the `sample_video` raw resource in as each video file. - `app/src/debug/res/drawable/preview_video` is a large placeholder image used by Composable Previews for video players; it's excluded from release builds. A small fallback `preview_video` diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt index 949a5c50..1ad34817 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt @@ -43,6 +43,15 @@ class SettingsDao( private val videoStorageLocationKey = stringPreferencesKey("videoStorageLocation") + /** Debug setting: pretend there is an SD card, stored in a folder inside the app's files */ + private val debugFakeSdCardEnabled = booleanPreferencesKey("debugFakeSdCardEnabled") + + /** Debug setting: whether the fake SD card is currently inserted */ + private val debugFakeSdCardInserted = booleanPreferencesKey("debugFakeSdCardInserted") + + /** Debug setting: pause during video moves, so there is time to interrupt them */ + private val debugSlowVideoMoves = booleanPreferencesKey("debugSlowVideoMoves") + suspend fun setResolution(resolution: Size) { val components = listOf(resolution.width, resolution.height) val string = components.joinToString(resolutionSeparator) @@ -137,6 +146,30 @@ class SettingsDao( } } + suspend fun setDebugFakeSdCardEnabled(enabled: Boolean) { + updatePrefs { set(debugFakeSdCardEnabled, enabled) } + } + + fun getDebugFakeSdCardEnabledFlow(): Flow { + return context.dataStore.data.map { it[debugFakeSdCardEnabled] ?: false } + } + + suspend fun setDebugFakeSdCardInserted(inserted: Boolean) { + updatePrefs { set(debugFakeSdCardInserted, inserted) } + } + + fun getDebugFakeSdCardInsertedFlow(): Flow { + return context.dataStore.data.map { it[debugFakeSdCardInserted] ?: true } + } + + suspend fun setDebugSlowVideoMoves(slow: Boolean) { + updatePrefs { set(debugSlowVideoMoves, slow) } + } + + fun getDebugSlowVideoMovesFlow(): Flow { + return context.dataStore.data.map { it[debugSlowVideoMoves] ?: false } + } + private suspend fun getPrefs() = context.dataStore.data.first() private suspend fun updatePrefs(block: MutablePreferences.() -> Unit) { diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt index a417a903..3bcf82ba 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt @@ -4,12 +4,14 @@ import android.content.Context import android.net.Uri import android.os.Environment import android.provider.MediaStore +import com.lukeneedham.videodiary.BuildConfig import com.lukeneedham.videodiary.R import com.lukeneedham.videodiary.data.mapper.ThumbnailFileNameMapper import com.lukeneedham.videodiary.data.mapper.VideoFileNameMapper import com.lukeneedham.common.logger.Logger import com.lukeneedham.videodiary.domain.model.VideoStorageLocation import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first @@ -35,6 +37,14 @@ class VideosDao( /** Held briefly by anything that changes the video files, and while a move is committed */ private val filesLock = Any() + // Debug-only (ignored in release builds), loaded from the settings by initialiseStorageLocation + @Volatile + private var debugFakeSdCardEnabled = false + @Volatile + private var debugFakeSdCardInserted = true + @Volatile + private var debugSlowVideoMoves = false + private val thumbnailsDir = File(context.filesDir, "thumbnails").apply { mkdirs() } @@ -62,6 +72,7 @@ class VideosDao( * Once the card is back, any videos recorded in the meantime are merged onto it. */ suspend fun initialiseStorageLocation() { + loadDebugSettings() val location = settingsDao.getVideoStorageLocationFlow().first() removableStorageAvailableMutable.value = isRemovableStorageAvailable() @@ -89,6 +100,13 @@ class VideosDao( switchVideosDir(wantedDir) } + private suspend fun loadDebugSettings() { + if (!BuildConfig.DEBUG) return + debugFakeSdCardEnabled = settingsDao.getDebugFakeSdCardEnabledFlow().first() + debugFakeSdCardInserted = settingsDao.getDebugFakeSdCardInsertedFlow().first() + debugSlowVideoMoves = settingsDao.getDebugSlowVideoMovesFlow().first() + } + fun isRemovableStorageAvailable(): Boolean = getVideosDir(VideoStorageLocation.RemovableStorage) != null @@ -117,6 +135,7 @@ class VideosDao( failOnConflict = true, ) try { + if (BuildConfig.DEBUG && debugSlowVideoMoves) delay(DEBUG_SLOW_MOVE_MILLIS) settingsDao.setVideoStorageLocation(location) try { synchronized(filesLock) { @@ -288,7 +307,14 @@ class VideosDao( private fun getVideosDir(location: VideoStorageLocation): File? = when (location) { VideoStorageLocation.Internal -> internalVideosDir - VideoStorageLocation.RemovableStorage -> context.getExternalFilesDirs(null) + VideoStorageLocation.RemovableStorage -> getRemovableVideosDir() + } + + private fun getRemovableVideosDir(): File? { + if (BuildConfig.DEBUG && debugFakeSdCardEnabled) { + return if (debugFakeSdCardInserted) File(context.filesDir, "fake_sd_card/videos") else null + } + return context.getExternalFilesDirs(null) .firstOrNull { directory -> directory != null && Environment.isExternalStorageRemovable(directory) && @@ -311,4 +337,8 @@ class VideosDao( val files = videosDir.listFiles() ?: return emptyList() return files.filterNot { it.name.endsWith(VideoFolderTransfer.TEMPORARY_SUFFIX) } } -} \ No newline at end of file + + private companion object { + const val DEBUG_SLOW_MOVE_MILLIS = 8_000L + } +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugPage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugPage.kt index 78767aa6..3e56ee99 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugPage.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugPage.kt @@ -17,11 +17,21 @@ fun DebugPage( ) { val allowRetakeForPastDays by viewModel.allowRetakeForPastDays.collectAsState() + val fakeSdCardEnabled by viewModel.fakeSdCardEnabled.collectAsState() + val fakeSdCardInserted by viewModel.fakeSdCardInserted.collectAsState() + val slowVideoMoves by viewModel.slowVideoMoves.collectAsState() + DebugPageContent( onFillWithMockDataClick = viewModel::fillWithMockData, allowRetakeForPastDays = allowRetakeForPastDays, onAllowRetakeForPastDaysChange = viewModel::setAllowRetakeForPastDays, onResyncThumbnailsClick = viewModel::resyncThumbnails, + fakeSdCardEnabled = fakeSdCardEnabled, + onFakeSdCardEnabledChange = viewModel::setFakeSdCardEnabled, + fakeSdCardInserted = fakeSdCardInserted, + onFakeSdCardInsertedChange = viewModel::setFakeSdCardInserted, + slowVideoMoves = slowVideoMoves, + onSlowVideoMovesChange = viewModel::setSlowVideoMoves, onOpenSdkDebug = onOpenSdkDebug, canGoBack = canGoBack, onBack = onBack, diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugPageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugPageContent.kt index e34c3fbc..c10dad16 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugPageContent.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugPageContent.kt @@ -48,6 +48,12 @@ fun DebugPageContent( allowRetakeForPastDays: Boolean, onAllowRetakeForPastDaysChange: (Boolean) -> Unit, onResyncThumbnailsClick: () -> Unit, + fakeSdCardEnabled: Boolean, + onFakeSdCardEnabledChange: (Boolean) -> Unit, + fakeSdCardInserted: Boolean, + onFakeSdCardInsertedChange: (Boolean) -> Unit, + slowVideoMoves: Boolean, + onSlowVideoMovesChange: (Boolean) -> Unit, onOpenSdkDebug: () -> Unit, canGoBack: Boolean, onBack: () -> Unit, @@ -107,6 +113,39 @@ fun DebugPageContent( ) } + Section(title = "Storage") { + DebugSwitchRow( + iconRes = R.drawable.movie, + accent = AccentAccept, + title = "Fake SD card", + description = "Pretends there is an SD card, kept in a folder inside the app's " + + "files, so Video storage can be tested on a phone without one", + checked = fakeSdCardEnabled, + onCheckedChange = onFakeSdCardEnabledChange, + ) + if (fakeSdCardEnabled) { + Divider() + DebugSwitchRow( + iconRes = R.drawable.movie, + accent = AccentAccept, + title = "Fake SD card inserted", + description = "Turn off to simulate removing the card, on to put it back", + checked = fakeSdCardInserted, + onCheckedChange = onFakeSdCardInsertedChange, + ) + } + Divider() + DebugSwitchRow( + iconRes = R.drawable.pause, + accent = AccentHighlight, + title = "Slow video moves", + description = "Adds an 8 second pause to moving videos between storage " + + "locations, so a move can be interrupted", + checked = slowVideoMoves, + onCheckedChange = onSlowVideoMovesChange, + ) + } + Section(title = "Diagnostics") { DebugActionRow( iconRes = R.drawable.bug, @@ -272,6 +311,12 @@ internal fun PreviewDebugPageContent() { allowRetakeForPastDays = true, onAllowRetakeForPastDaysChange = {}, onResyncThumbnailsClick = {}, + fakeSdCardEnabled = true, + onFakeSdCardEnabledChange = {}, + fakeSdCardInserted = true, + onFakeSdCardInsertedChange = {}, + slowVideoMoves = false, + onSlowVideoMovesChange = {}, onOpenSdkDebug = {}, canGoBack = true, onBack = {}, diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugViewModel.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugViewModel.kt index 34f637cb..49a5e319 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugViewModel.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugViewModel.kt @@ -21,6 +21,15 @@ class DebugViewModel( val allowRetakeForPastDays: StateFlow = settingsDao.getAllowEditPastDaysFlow() .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val fakeSdCardEnabled: StateFlow = settingsDao.getDebugFakeSdCardEnabledFlow() + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val fakeSdCardInserted: StateFlow = settingsDao.getDebugFakeSdCardInsertedFlow() + .stateIn(viewModelScope, SharingStarted.Eagerly, true) + + val slowVideoMoves: StateFlow = settingsDao.getDebugSlowVideoMovesFlow() + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + fun fillWithMockData() { mockDataRepository.fillWithMockData() } @@ -31,6 +40,28 @@ class DebugViewModel( } } + fun setFakeSdCardEnabled(enabled: Boolean) { + applyStorageSetting { settingsDao.setDebugFakeSdCardEnabled(enabled) } + } + + fun setFakeSdCardInserted(inserted: Boolean) { + applyStorageSetting { settingsDao.setDebugFakeSdCardInserted(inserted) } + } + + fun setSlowVideoMoves(slow: Boolean) { + applyStorageSetting { settingsDao.setDebugSlowVideoMoves(slow) } + } + + /** Saves the setting, then makes the diary react to it, as if the SD card had changed */ + private fun applyStorageSetting(save: suspend () -> Unit) { + viewModelScope.launch { + save() + withContext(ioDispatcher) { + videosDao.initialiseStorageLocation() + } + } + } + fun resyncThumbnails() { viewModelScope.launch { withContext(ioDispatcher) { From dd259e18787944a94a579a83168be779aa1c9b70 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 10:26:07 +0000 Subject: [PATCH 09/17] Fix calendar crash when the set of videos changes The calendar only picked its day index on the first load. When the diary shrinks (e.g. the SD card holding every video is removed), the old index pointed past the end of the new, shorter list and CalendarPageContent crashed. Keep the same date selected when the list changes, or fall back to the last day. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FBqFYw2zBG9C9sT7ABYdMN --- .../ui/feature/calendar/CalendarViewModel.kt | 23 +++++++-- .../calendar/ResolveCurrentDayIndexTest.kt | 51 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/lukeneedham/videodiary/ui/feature/calendar/ResolveCurrentDayIndexTest.kt diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/CalendarViewModel.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/CalendarViewModel.kt index 0532c6b3..9f53ffc5 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/CalendarViewModel.kt @@ -49,11 +49,9 @@ class CalendarViewModel( viewModelScope.launch { calendarRepository.allDays.collect { newDays -> val oldDays = days + val oldIndex = currentDayIndex days = newDays - if (oldDays.isEmpty()) { - // This is the first load - currentDayIndex = newDays.lastIndex - } + currentDayIndex = resolveCurrentDayIndex(oldDays, oldIndex, newDays) } } } @@ -74,4 +72,19 @@ class CalendarViewModel( currentDayIndex = index } } -} \ No newline at end of file +} + +/** + * The index to show once the days change to [newDays]. The list can grow or shrink at the start + * when videos appear or disappear (for example when the storage location changes), so the index + * alone is not enough: stay on the same date if it still exists, otherwise go to the last day. + */ +internal fun resolveCurrentDayIndex( + oldDays: List, + oldIndex: Int, + newDays: List, +): Int { + val currentDate = oldDays.getOrNull(oldIndex)?.date + val newIndex = newDays.indexOfFirst { it.date == currentDate } + return if (newIndex != -1) newIndex else newDays.lastIndex.coerceAtLeast(0) +} diff --git a/app/src/test/java/com/lukeneedham/videodiary/ui/feature/calendar/ResolveCurrentDayIndexTest.kt b/app/src/test/java/com/lukeneedham/videodiary/ui/feature/calendar/ResolveCurrentDayIndexTest.kt new file mode 100644 index 00000000..2570426f --- /dev/null +++ b/app/src/test/java/com/lukeneedham/videodiary/ui/feature/calendar/ResolveCurrentDayIndexTest.kt @@ -0,0 +1,51 @@ +package com.lukeneedham.videodiary.ui.feature.calendar + +import com.lukeneedham.videodiary.domain.model.Day +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate + +class ResolveCurrentDayIndexTest { + private val today = LocalDate.of(2026, 10, 25) + + /** [count] days ending today */ + private fun days(count: Int) = (count - 1 downTo 0).map { + Day(date = today.minusDays(it.toLong()), videoFile = null, thumbnailFile = null) + } + + @Test + fun firstLoadGoesToLastDay() { + assertEquals(59, resolveCurrentDayIndex(emptyList(), 0, days(60))) + } + + @Test + fun listShrinkingFromTheStartDoesNotLeaveIndexOutOfBounds() { + // Looking at today in a 59 day diary, then all the videos disappear + assertEquals(0, resolveCurrentDayIndex(days(59), 58, days(1))) + } + + @Test + fun listGrowingAtTheStartStaysOnTheSameDate() { + // Looking at today when the diary only had today, then older videos come back + assertEquals(59, resolveCurrentDayIndex(days(1), 0, days(60))) + } + + @Test + fun staysOnTheSameDateWhenOlderDaysAreRemoved() { + val old = days(10) + val newDays = days(5) + // Index 7 in the old list is 2 days before today, which is index 2 in the new one + assertEquals(2, resolveCurrentDayIndex(old, 7, newDays)) + } + + @Test + fun goesToLastDayWhenTheCurrentDateIsGone() { + // Looking at a day older than anything left in the diary + assertEquals(4, resolveCurrentDayIndex(days(10), 0, days(5))) + } + + @Test + fun emptyNewListGivesZero() { + assertEquals(0, resolveCurrentDayIndex(days(3), 1, emptyList())) + } +} From 3feebecfd33acf386114332330f9723a2203337c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 13:42:56 +0000 Subject: [PATCH 10/17] Block the app with an SD card missing page when the SD card is not there When videos are stored on the SD card and it is missing - on launch, on resume, or when it is ejected while the app is running (via storage broadcasts) - the whole app is replaced by a page asking for the card to be reinserted. Back closes the app. Debug builds get a Reinsert SD card button that mocks putting the fake card back, which returns to the home page. Removes the internal storage fallback and its notice. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FBqFYw2zBG9C9sT7ABYdMN --- CLAUDE.md | 3 + .../lukeneedham/videodiary/MainActivity.kt | 15 +++ .../data/android/RemovableStorageMonitor.kt | 69 +++++++++++ .../videodiary/data/persistence/VideosDao.kt | 31 +++-- .../lukeneedham/videodiary/di/KoinModule.kt | 8 ++ .../ui/feature/storage/SdCardMissingPage.kt | 21 ++++ .../storage/SdCardMissingPageContent.kt | 109 ++++++++++++++++++ .../ui/feature/storage/VideoStoragePage.kt | 2 - .../storage/VideoStoragePageContent.kt | 13 +-- .../feature/storage/VideoStorageViewModel.kt | 2 - .../lukeneedham/videodiary/ui/root/Root.kt | 10 ++ .../videodiary/ui/root/RootState.kt | 2 + .../videodiary/ui/root/RootViewModel.kt | 25 ++++ 13 files changed, 278 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/com/lukeneedham/videodiary/data/android/RemovableStorageMonitor.kt create mode 100644 app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/SdCardMissingPage.kt create mode 100644 app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/SdCardMissingPageContent.kt diff --git a/CLAUDE.md b/CLAUDE.md index f56f6a97..4b2a4e55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,6 +159,9 @@ docs: `docs/using-the-library.md` there). The app has no logger of its own. The Debug page's Storage section fakes an SD card for testing Video storage on a phone without one: "Fake SD card" swaps the removable storage folder for `filesDir/fake_sd_card/videos`, "Fake SD card inserted" simulates removing and re-inserting it, and "Slow video moves" adds an 8 second pause to a move so it can be interrupted (all `BuildConfig.DEBUG` only, applied in `VideosDao`). + When the videos are stored on the SD card and it is missing (checked on launch, on every resume, and on storage broadcasts via + `RemovableStorageMonitor`), `RootState.SdCardMissing` replaces the whole app with `SdCardMissingPage`; back closes the app. In debug builds that + page has a "Reinsert SD card (debug)" button that turns the fake SD card on and inserts it. Mock entries are populated by copying the `sample_video` raw resource in as each video file. - `app/src/debug/res/drawable/preview_video` is a large placeholder image used by Composable Previews for video players; it's excluded from release builds. A small fallback `preview_video` diff --git a/app/src/main/java/com/lukeneedham/videodiary/MainActivity.kt b/app/src/main/java/com/lukeneedham/videodiary/MainActivity.kt index 83b60ac4..fe796441 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/MainActivity.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/MainActivity.kt @@ -7,6 +7,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.app.ActivityCompat import androidx.core.view.WindowCompat +import com.lukeneedham.videodiary.data.android.RemovableStorageMonitor import com.lukeneedham.videodiary.domain.model.Orientation import com.lukeneedham.videodiary.domain.model.ShareRequest import com.lukeneedham.videodiary.ui.media.VideoPlayerHolder @@ -18,6 +19,7 @@ import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { private val sharer: Sharer by inject() private val videoPlayerHolder: VideoPlayerHolder by inject() + private val removableStorageMonitor: RemovableStorageMonitor by inject() private val permissionResultListenerHolder = PermissionResultListenerHolder() @@ -32,10 +34,21 @@ class MainActivity : ComponentActivity() { setOrientation = ::setOrientation, requestPermission = ::requestPermission, permissionResultListenerHolder = permissionResultListenerHolder, + closeApp = ::finish, ) } } + override fun onStart() { + super.onStart() + removableStorageMonitor.start() + } + + override fun onStop() { + super.onStop() + removableStorageMonitor.stop() + } + override fun onPause() { super.onPause() videoPlayerHolder.onAppPause() @@ -44,6 +57,8 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() videoPlayerHolder.onAppResume() + // The card may have been removed while the app was paused, and the event missed + removableStorageMonitor.recheck() } @Suppress("OVERRIDE_DEPRECATION") diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/android/RemovableStorageMonitor.kt b/app/src/main/java/com/lukeneedham/videodiary/data/android/RemovableStorageMonitor.kt new file mode 100644 index 00000000..6e6ce37b --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/data/android/RemovableStorageMonitor.kt @@ -0,0 +1,69 @@ +package com.lukeneedham.videodiary.data.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import com.lukeneedham.common.logger.Logger +import com.lukeneedham.videodiary.data.persistence.VideosDao +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Tells [VideosDao] when the SD card may have been inserted or removed, so the app can react + * to it while it is running. Call [start] when the app comes to the foreground and [stop] when + * it leaves, and [recheck] whenever it is resumed in case an event was missed. + */ +class RemovableStorageMonitor( + private val context: Context, + private val videosDao: VideosDao, + ioDispatcher: CoroutineDispatcher, +) { + private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) + + private var isStarted = false + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Logger.d("Storage event: ${intent.action}") + recheck() + } + } + + fun start() { + if (!isStarted) { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_MEDIA_MOUNTED) + addAction(Intent.ACTION_MEDIA_UNMOUNTED) + addAction(Intent.ACTION_MEDIA_EJECT) + addAction(Intent.ACTION_MEDIA_REMOVED) + addAction(Intent.ACTION_MEDIA_BAD_REMOVAL) + addAction(Intent.ACTION_MEDIA_UNMOUNTABLE) + // These events only carry a "file" URI, and are not delivered without this + addDataScheme("file") + } + context.registerReceiver(receiver, filter) + isStarted = true + } + recheck() + } + + fun stop() { + if (isStarted) { + context.unregisterReceiver(receiver) + isStarted = false + } + } + + fun recheck() { + scope.launch { + try { + videosDao.initialiseStorageLocation() + } catch (e: Exception) { + Logger.w("Could not re-check the storage location", e) + } + } + } +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt index 3bcf82ba..14edce99 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt @@ -15,6 +15,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.File import java.time.LocalDate @@ -51,7 +53,8 @@ class VideosDao( private val allVideosMutable = MutableStateFlow(loadAllVideos()) private val removableStorageAvailableMutable = MutableStateFlow(isRemovableStorageAvailable()) - private val usingFallbackStorageMutable = MutableStateFlow(false) + private val sdCardMissingMutable = MutableStateFlow(false) + private val initialiseMutex = Mutex() /** A Flow of all video files in the diary, unordered */ val allVideos = allVideosMutable.asStateFlow() @@ -59,32 +62,28 @@ class VideosDao( val removableStorageAvailable = removableStorageAvailableMutable.asStateFlow() /** - * True when the chosen location is the SD card but it is not available right now, - * so internal storage is being used until it comes back. + * True when the chosen location is the SD card but it is not available right now. + * The app does not work in this state: it asks the user to put the SD card back. */ - val usingFallbackStorage = usingFallbackStorageMutable.asStateFlow() + val sdCardMissing = sdCardMissingMutable.asStateFlow() /** * Points the diary at the saved storage location. Also re-checks whether the SD card is * available, so call it again when the SD card may have been inserted or removed. * - * If the saved location is the SD card but it is missing, internal storage is used for now. - * Once the card is back, any videos recorded in the meantime are merged onto it. + * If the saved location is the SD card but it is missing, [sdCardMissing] becomes true and + * nothing else changes: the videos on the card are not reachable until it is back. */ - suspend fun initialiseStorageLocation() { + suspend fun initialiseStorageLocation() = initialiseMutex.withLock { loadDebugSettings() val location = settingsDao.getVideoStorageLocationFlow().first() removableStorageAvailableMutable.value = isRemovableStorageAvailable() val wantedDir = getVideosDir(location) - if (wantedDir == null) { - usingFallbackStorageMutable.value = true - switchVideosDir(internalVideosDir) - return - } - usingFallbackStorageMutable.value = false + sdCardMissingMutable.value = wantedDir == null + if (wantedDir == null) return@withLock - // Videos may be in the other folder after an interrupted move, or after a fallback + // Videos may be in the other folder after an interrupted move val otherDir = if (wantedDir == internalVideosDir) { getVideosDir(VideoStorageLocation.RemovableStorage) } else { @@ -125,7 +124,7 @@ class VideosDao( val sourceDir = videosDir if (sourceDir.canonicalPath == destinationDir.canonicalPath) { settingsDao.setVideoStorageLocation(location) - usingFallbackStorageMutable.value = false + sdCardMissingMutable.value = false return@runCatching } @@ -154,7 +153,7 @@ class VideosDao( throw e } - usingFallbackStorageMutable.value = false + sdCardMissingMutable.value = false refreshVideosState() // The move already worked, so leftovers are just wasted space until the next merge transfer.commit().forEach { Logger.w("Could not delete old video $it") } diff --git a/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt b/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt index 7e0e6e14..15e81e76 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt @@ -2,6 +2,7 @@ package com.lukeneedham.videodiary.di import android.net.Uri import com.lukeneedham.videodiary.data.android.PermissionChecker +import com.lukeneedham.videodiary.data.android.RemovableStorageMonitor import com.lukeneedham.videodiary.data.mapper.ThumbnailFileNameMapper import com.lukeneedham.videodiary.data.mapper.VideoFileNameMapper import com.lukeneedham.videodiary.data.persistence.SavedRecapsDao @@ -72,6 +73,13 @@ object KoinModule { context = androidContext(), ) } + single { + RemovableStorageMonitor( + context = androidContext(), + videosDao = get(), + ioDispatcher = get(KoinQualifier.Dispatcher.io), + ) + } single { VideosDao( context = androidContext(), diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/SdCardMissingPage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/SdCardMissingPage.kt new file mode 100644 index 00000000..45f7aea6 --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/SdCardMissingPage.kt @@ -0,0 +1,21 @@ +package com.lukeneedham.videodiary.ui.feature.storage + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable + +/** + * Shown instead of the whole app when videos are stored on the SD card and it is not there. + * Back closes the app: there is nowhere else to go. + */ +@Composable +fun SdCardMissingPage( + canMockReinsert: Boolean, + onMockReinsertClick: () -> Unit, + onClose: () -> Unit, +) { + BackHandler(onBack = onClose) + SdCardMissingPageContent( + canMockReinsert = canMockReinsert, + onMockReinsertClick = onMockReinsertClick, + ) +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/SdCardMissingPageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/SdCardMissingPageContent.kt new file mode 100644 index 00000000..b602e4f9 --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/SdCardMissingPageContent.kt @@ -0,0 +1,109 @@ +package com.lukeneedham.videodiary.ui.feature.storage + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +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.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.lukeneedham.videodiary.R +import com.lukeneedham.videodiary.ui.feature.common.Button +import com.lukeneedham.videodiary.ui.theme.AccentHighlight +import com.lukeneedham.videodiary.ui.theme.AppBackground +import com.lukeneedham.videodiary.ui.theme.AppSurfaceVariant +import com.lukeneedham.videodiary.ui.theme.Typography + +@Composable +fun SdCardMissingPageContent( + canMockReinsert: Boolean, + onMockReinsertClick: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .background(AppBackground) + .padding(32.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(18.dp)) + .background(AccentHighlight.copy(alpha = 0.15f)) + ) { + Image( + painter = painterResource(R.drawable.info), + contentDescription = null, + colorFilter = ColorFilter.tint(AccentHighlight), + modifier = Modifier.size(40.dp), + ) + } + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Whoa there!", + color = Color.White, + fontSize = Typography.Size.big, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "To use this app, you need to reinsert your SD card. " + + "Your diary videos are stored on it, so the app will open again as soon as " + + "the card is back.", + color = Color.White.copy(alpha = 0.6f), + fontSize = Typography.Size.medium, + textAlign = TextAlign.Center, + ) + if (canMockReinsert) { + Spacer(modifier = Modifier.height(32.dp)) + Button( + text = "Reinsert SD card (debug)", + onClick = onMockReinsertClick, + backgroundColor = AppSurfaceVariant, + contentPadding = PaddingValues( + horizontal = 24.dp, + vertical = 10.dp, + ), + ) + } + } +} + +@Preview +@Composable +private fun PreviewSdCardMissingPageContent() { + SdCardMissingPageContent( + canMockReinsert = false, + onMockReinsertClick = {}, + ) +} + +@Preview +@Composable +private fun PreviewSdCardMissingPageContentDebug() { + SdCardMissingPageContent( + canMockReinsert = true, + onMockReinsertClick = {}, + ) +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt index 8caaabac..de87ce91 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt @@ -12,11 +12,9 @@ fun VideoStoragePage( ) { val location by viewModel.location.collectAsState() val isRemovableStorageAvailable by viewModel.isRemovableStorageAvailable.collectAsState() - val isUsingFallbackStorage by viewModel.isUsingFallbackStorage.collectAsState() VideoStoragePageContent( location = location, isRemovableStorageAvailable = isRemovableStorageAvailable, - isUsingFallbackStorage = isUsingFallbackStorage, isChangingLocation = viewModel.isChangingLocation, error = viewModel.error, onLocationSelected = viewModel::selectLocation, diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt index 2c13b97e..7152361b 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt @@ -46,7 +46,6 @@ import com.lukeneedham.videodiary.ui.theme.Typography fun VideoStoragePageContent( location: VideoStorageLocation, isRemovableStorageAvailable: Boolean, - isUsingFallbackStorage: Boolean, isChangingLocation: Boolean, error: String?, onLocationSelected: (VideoStorageLocation) -> Unit, @@ -121,14 +120,6 @@ fun VideoStoragePageContent( ) } } - if (isUsingFallbackStorage) { - Notice( - accent = AccentHighlight, - text = "The SD card is not available, so videos are temporarily being kept " + - "in internal storage. They will be moved back to the SD card when it " + - "is inserted", - ) - } if (error != null) { Notice(accent = AccentRecord, text = error) } @@ -269,7 +260,6 @@ private fun PreviewVideoStoragePageContent() { VideoStoragePageContent( location = VideoStorageLocation.Internal, isRemovableStorageAvailable = true, - isUsingFallbackStorage = false, isChangingLocation = false, error = null, onLocationSelected = {}, @@ -280,11 +270,10 @@ private fun PreviewVideoStoragePageContent() { @Preview @Composable -private fun PreviewVideoStoragePageContentFallbackAndError() { +private fun PreviewVideoStoragePageContentUnavailableAndError() { VideoStoragePageContent( location = VideoStorageLocation.RemovableStorage, isRemovableStorageAvailable = false, - isUsingFallbackStorage = true, isChangingLocation = false, error = "Could not move videos. Your current storage location has not changed.", onLocationSelected = {}, diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt index cfc34001..8e3108a0 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt @@ -26,8 +26,6 @@ class VideoStorageViewModel( val isRemovableStorageAvailable: StateFlow = videosDao.removableStorageAvailable .stateIn(viewModelScope, SharingStarted.Eagerly, videosDao.isRemovableStorageAvailable()) - val isUsingFallbackStorage: StateFlow = videosDao.usingFallbackStorage - var isChangingLocation by mutableStateOf(false) private set diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/root/Root.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/root/Root.kt index 6092be34..cfc996eb 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/root/Root.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/root/Root.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.graphics.Color import com.lukeneedham.videodiary.domain.model.Orientation import com.lukeneedham.videodiary.domain.model.ShareRequest import com.lukeneedham.videodiary.ui.feature.permissions.RequestPermissionsPage +import com.lukeneedham.videodiary.ui.feature.storage.SdCardMissingPage import com.lukeneedham.videodiary.ui.navigation.normal.NormalRouter import com.lukeneedham.videodiary.ui.navigation.setup.SetupRouter import com.lukeneedham.videodiary.ui.permissions.PermissionResultListenerHolder @@ -26,6 +27,7 @@ fun Root( setOrientation: (Orientation) -> Unit, requestPermission: (permissions: String) -> Unit, permissionResultListenerHolder: PermissionResultListenerHolder, + closeApp: () -> Unit, ) { val viewModel: RootViewModel = koinViewModel() @@ -76,6 +78,14 @@ fun Root( ) } + RootState.SdCardMissing -> { + SdCardMissingPage( + canMockReinsert = viewModel.canMockSdCardReinsert, + onMockReinsertClick = viewModel::mockSdCardReinsert, + onClose = closeApp, + ) + } + RootState.NeedsSetup -> { SetupRouter( onSetupComplete = viewModel::onSetupComplete, diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/root/RootState.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/root/RootState.kt index 51b89042..1645048c 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/root/RootState.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/root/RootState.kt @@ -3,6 +3,8 @@ package com.lukeneedham.videodiary.ui.root sealed interface RootState { object Loading : RootState object NeedsPermissions : RootState + /** The app is set to store videos on the SD card, but it is not there. Nothing else works. */ + object SdCardMissing : RootState object NeedsSetup : RootState object Ready : RootState } \ No newline at end of file diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/root/RootViewModel.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/root/RootViewModel.kt index 22586c15..79dbe1ac 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/root/RootViewModel.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/root/RootViewModel.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lukeneedham.videodiary.BuildConfig import com.lukeneedham.videodiary.data.android.PermissionChecker import com.lukeneedham.videodiary.data.persistence.SettingsDao import com.lukeneedham.videodiary.data.persistence.VideosDao @@ -23,6 +24,10 @@ class RootViewModel( private var isMissingPermissions: Boolean? by mutableStateOf(null) private var hasSetupCompleted: Boolean? by mutableStateOf(null) private var isStorageInitialised: Boolean by mutableStateOf(false) + private var isSdCardMissing: Boolean by mutableStateOf(false) + + /** Debug builds only: whether the SD card missing page can pretend the card is put back */ + val canMockSdCardReinsert: Boolean get() = BuildConfig.DEBUG var orientationState: RootOrientationState by mutableStateOf(RootOrientationState.Loading) private set @@ -32,6 +37,9 @@ class RootViewModel( val hasSetupCompleted = hasSetupCompleted ?: return@derivedStateOf RootState.Loading if (!isStorageInitialised) return@derivedStateOf RootState.Loading + // Nothing in the app works without the videos, so don't let the user get to any of it + if (isSdCardMissing) return@derivedStateOf RootState.SdCardMissing + // First-time (or interrupted) setup always goes through the setup wizard, which shows the // onboarding intro before requesting permissions part-way through. if (!hasSetupCompleted) return@derivedStateOf RootState.NeedsSetup @@ -72,6 +80,23 @@ class RootViewModel( } isStorageInitialised = true } + viewModelScope.launch { + videosDao.sdCardMissing.collect { isSdCardMissing = it } + } + } + + /** + * Debug builds only: puts the fake SD card back, as if it had been reinserted. + * Turns the fake SD card on if it was off, so this can never be a dead end. + */ + fun mockSdCardReinsert() { + viewModelScope.launch { + settingsDao.setDebugFakeSdCardEnabled(true) + settingsDao.setDebugFakeSdCardInserted(true) + withContext(ioDispatcher) { + videosDao.initialiseStorageLocation() + } + } } fun onPermissionsAcquired() { From feb1aeddc296e18f180de0227fc86dc84c7c3796 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 13:57:20 +0000 Subject: [PATCH 11/17] Never move videos automatically; add lost-card hint to the SD card missing page Videos are now only moved between internal storage and the SD card when the user switches location. Removes the startup merge from the other folder, and the now-unused keep-on-conflict mode of VideoFolderTransfer. The SD card missing page tells users who lost their card how to start over. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FBqFYw2zBG9C9sT7ABYdMN --- .../data/persistence/VideoFolderTransfer.kt | 16 ++++------ .../videodiary/data/persistence/VideosDao.kt | 25 ++-------------- .../storage/SdCardMissingPageContent.kt | 8 +++++ .../persistence/VideoFolderTransferTest.kt | 29 +++++-------------- 4 files changed, 23 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransfer.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransfer.kt index 7ac577b6..42101727 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransfer.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransfer.kt @@ -46,13 +46,12 @@ class VideoFolderTransfer private constructor( * Copies every video in [from] to [to]. * * If [to] already holds a file with the same name and size, it is treated as an - * already-copied video. If it holds one with a different size, that is a conflict: - * with [failOnConflict] this throws, otherwise the existing file in [to] is kept - * and the one in [from] is left untouched. + * already-copied video. If it holds one with a different size, that is a conflict + * and this throws, rather than overwrite a video. * * On any failure the copies made so far are removed and the sources are untouched. */ - fun copy(from: File, to: File, failOnConflict: Boolean): VideoFolderTransfer { + fun copy(from: File, to: File): VideoFolderTransfer { to.mkdirs() check(to.isDirectory) { "Could not create destination directory" } deleteTemporaryFiles(to) @@ -64,13 +63,10 @@ class VideoFolderTransfer private constructor( videoFiles(from).forEach { source -> val destination = File(to, source.name) if (destination.exists()) { - if (destination.length() == source.length()) { - toMove += source - } else { - check(!failOnConflict) { - "A different video named ${source.name} already exists in the destination" - } + check(destination.length() == source.length()) { + "A different video named ${source.name} already exists in the destination" } + toMove += source return@forEach } diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt index 14edce99..fcee83f9 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt @@ -73,6 +73,7 @@ class VideosDao( * * If the saved location is the SD card but it is missing, [sdCardMissing] becomes true and * nothing else changes: the videos on the card are not reachable until it is back. + * Videos are only ever moved between the two folders by [moveVideosTo], when the user asks. */ suspend fun initialiseStorageLocation() = initialiseMutex.withLock { loadDebugSettings() @@ -83,19 +84,6 @@ class VideosDao( sdCardMissingMutable.value = wantedDir == null if (wantedDir == null) return@withLock - // Videos may be in the other folder after an interrupted move - val otherDir = if (wantedDir == internalVideosDir) { - getVideosDir(VideoStorageLocation.RemovableStorage) - } else { - internalVideosDir - } - if (otherDir != null && otherDir != wantedDir) { - try { - mergeInto(wantedDir, from = otherDir) - } catch (e: Exception) { - Logger.w("Could not merge videos into $wantedDir", e) - } - } switchVideosDir(wantedDir) } @@ -131,7 +119,6 @@ class VideosDao( val transfer = VideoFolderTransfer.copy( from = sourceDir, to = destinationDir, - failOnConflict = true, ) try { if (BuildConfig.DEBUG && debugSlowVideoMoves) delay(DEBUG_SLOW_MOVE_MILLIS) @@ -155,19 +142,11 @@ class VideosDao( sdCardMissingMutable.value = false refreshVideosState() - // The move already worked, so leftovers are just wasted space until the next merge + // The move already worked, so leftovers are just wasted space transfer.commit().forEach { Logger.w("Could not delete old video $it") } }.onFailure { Logger.w("Failed to move videos to $location", it) } } - /** Moves videos that are in [from] but not in [into], then deletes them from [from] */ - private fun mergeInto(into: File, from: File) { - VideoFolderTransfer.deleteTemporaryFiles(from) - if (from.listFiles().isNullOrEmpty()) return - val transfer = VideoFolderTransfer.copy(from = from, to = into, failOnConflict = false) - transfer.commit().forEach { Logger.w("Could not delete merged video $it") } - } - private fun switchVideosDir(dir: File) { synchronized(filesLock) { dir.mkdirs() diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/SdCardMissingPageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/SdCardMissingPageContent.kt index b602e4f9..b3394f6f 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/SdCardMissingPageContent.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/SdCardMissingPageContent.kt @@ -75,6 +75,14 @@ fun SdCardMissingPageContent( fontSize = Typography.Size.medium, textAlign = TextAlign.Center, ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Lost your card? You can start over by clearing this app's data in " + + "Android settings.", + color = Color.White.copy(alpha = 0.4f), + fontSize = Typography.Size.extraSmall, + textAlign = TextAlign.Center, + ) if (canMockReinsert) { Spacer(modifier = Modifier.height(32.dp)) Button( diff --git a/app/src/test/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransferTest.kt b/app/src/test/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransferTest.kt index 9039602f..8075fbe1 100644 --- a/app/src/test/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransferTest.kt +++ b/app/src/test/java/com/lukeneedham/videodiary/data/persistence/VideoFolderTransferTest.kt @@ -30,7 +30,7 @@ class VideoFolderTransferTest { from.write("a", "aaa") from.write("b", "bb") - val transfer = VideoFolderTransfer.copy(from, to, failOnConflict = true) + val transfer = VideoFolderTransfer.copy(from, to) assertEquals("aaa", File(to, "a").readText()) assertTrue("Sources are untouched until commit", File(from, "a").exists()) @@ -43,7 +43,7 @@ class VideoFolderTransferTest { fun rollbackRemovesCopiesAndKeepsSources() { from.write("a", "aaa") - VideoFolderTransfer.copy(from, to, failOnConflict = true).rollback() + VideoFolderTransfer.copy(from, to).rollback() assertEquals("aaa", File(from, "a").readText()) assertEquals(emptyList(), to.list()!!.toList()) @@ -57,7 +57,7 @@ class VideoFolderTransferTest { to.write("b", "different length") try { - VideoFolderTransfer.copy(from, to, failOnConflict = true) + VideoFolderTransfer.copy(from, to) fail("Expected a conflict") } catch (e: IllegalStateException) { // expected @@ -69,28 +69,13 @@ class VideoFolderTransferTest { assertEquals("different length", File(to, "b").readText()) } - @Test - fun conflictIsKeptAsIsWhenNotFailing() { - from.write("a", "aaa") - from.write("b", "bbb") - to.mkdirs() - to.write("b", "different length") - - val transfer = VideoFolderTransfer.copy(from, to, failOnConflict = false) - transfer.commit() - - assertEquals("different length", File(to, "b").readText()) - assertEquals("aaa", File(to, "a").readText()) - assertEquals("The conflicting source must not be deleted", listOf("b"), from.list()!!.toList()) - } - @Test fun alreadyCopiedVideoIsNotAConflictAndIsNotRolledBack() { from.write("a", "aaa") to.mkdirs() to.write("a", "xyz") - val transfer = VideoFolderTransfer.copy(from, to, failOnConflict = true) + val transfer = VideoFolderTransfer.copy(from, to) transfer.rollback() assertTrue("Rollback must not delete a file it did not create", File(to, "a").exists()) @@ -102,7 +87,7 @@ class VideoFolderTransferTest { to.mkdirs() to.write(".a${VideoFolderTransfer.TEMPORARY_SUFFIX}", "half") - VideoFolderTransfer.copy(from, to, failOnConflict = true) + VideoFolderTransfer.copy(from, to) assertEquals(listOf("a"), to.list()!!.toList()) } @@ -111,7 +96,7 @@ class VideoFolderTransferTest { fun temporaryFilesInSourceAreNotMoved() { from.write(".a${VideoFolderTransfer.TEMPORARY_SUFFIX}", "half") - VideoFolderTransfer.copy(from, to, failOnConflict = true).commit() + VideoFolderTransfer.copy(from, to).commit() assertEquals(emptyList(), to.list()!!.toList()) } @@ -119,7 +104,7 @@ class VideoFolderTransferTest { @Test fun detectsSourceChangedAfterCopy() { from.write("a", "aaa") - val transfer = VideoFolderTransfer.copy(from, to, failOnConflict = true) + val transfer = VideoFolderTransfer.copy(from, to) assertTrue(transfer.isSourceUnchanged()) from.write("new", "recorded during the copy") From 114e0f1dcf1b1ffc28294d37754a23a31889ff59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:12:44 +0000 Subject: [PATCH 12/17] Add a tester build type and a workflow that publishes it The tester build type is signed with the debug keystore but has BuildConfig.DEBUG false, so it has no debug settings, fake SD card or extra logging. The manual Tester Release workflow builds it and publishes it as a GitHub pre-release. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FBqFYw2zBG9C9sT7ABYdMN --- .github/workflows/tester_release.yml | 46 ++++++++++++++++++++++++++++ app/build.gradle.kts | 9 ++++++ docs/Release.md | 8 +++++ 3 files changed, 63 insertions(+) create mode 100644 .github/workflows/tester_release.yml diff --git a/.github/workflows/tester_release.yml b/.github/workflows/tester_release.yml new file mode 100644 index 00000000..973bad49 --- /dev/null +++ b/.github/workflows/tester_release.yml @@ -0,0 +1,46 @@ +name: Tester Release + +# Builds the "tester" variant (debug-signed, but without the debug flag or any debug-only features) +# and publishes it as a GitHub pre-release, so it can be sent to someone to try out. +# Run it from the branch to test: Actions > Tester Release > Run workflow. +# Note: the "Delete pre-releases" workflow removes these along with every other pre-release. + +on: + workflow_dispatch: + inputs: + notes: + description: 'What to say about this build' + required: false + type: string + default: '' + +permissions: + contents: write + +jobs: + tester: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - uses: gradle/actions/setup-gradle@v4 + + - name: Build the tester APK + run: ./gradlew assembleTester + + - name: Publish the pre-release + env: + GH_TOKEN: ${{ github.token }} + NOTES: ${{ inputs.notes }} + run: | + tag="tester-${{ github.run_number }}" + gh release create "$tag" app/build/outputs/apk/tester/app-tester.apk \ + --prerelease \ + --target "$GITHUB_SHA" \ + --title "Tester build ${{ github.run_number }} (${GITHUB_REF_NAME})" \ + --notes "${NOTES:-Tester build of ${GITHUB_REF_NAME} at ${GITHUB_SHA::7}.}" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a0fccc2e..f7898bb1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,6 +43,15 @@ android { applicationIdSuffix = ".dbg" signingConfig = signingConfigs.getByName("debugConfig") } + // For sending a build to testers: signed with the debug keystore, so it installs without the + // official key, but with BuildConfig.DEBUG false, so none of the debug-only features + // (debug settings, fake SD card, extra logging) are in it. Installs next to the other builds. + create("tester") { + initWith(getByName("debug")) + isDebuggable = false + applicationIdSuffix = ".tester" + matchingFallbacks += listOf("release", "debug") + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/docs/Release.md b/docs/Release.md index fc02097f..fc375a2d 100644 --- a/docs/Release.md +++ b/docs/Release.md @@ -7,3 +7,11 @@ This app is distributed on F-Droid. To make a new release: 5. On Github create a new release from the latest tag, and upload the signed apk You're done! The F-Droid CI should now handle publishing a new release automatically + +## Tester builds + +To send a build to someone to try out, run the "Tester Release" workflow (Actions > Tester Release > Run workflow) from the branch to test. +It builds the `tester` build type: signed with the debug keystore, with the application ID suffix `.tester`, and with `BuildConfig.DEBUG` false, +so it has none of the debug-only features (debug settings, fake SD card, verbose logging). It publishes the APK as a GitHub pre-release, +which the "Delete pre-releases" workflow removes with the others. Locally: `./gradlew assembleTester`. + From 38a4e07c6f1fe3dc445c0f0ee02dcd7a3d302366 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:14:27 +0000 Subject: [PATCH 13/17] Rename the tester build type to beta The Android Gradle plugin rejects build type names that start with 'test', which broke every build. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FBqFYw2zBG9C9sT7ABYdMN --- .github/workflows/tester_release.yml | 8 ++++---- app/build.gradle.kts | 6 +++--- docs/Release.md | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tester_release.yml b/.github/workflows/tester_release.yml index 973bad49..02b357c2 100644 --- a/.github/workflows/tester_release.yml +++ b/.github/workflows/tester_release.yml @@ -1,6 +1,6 @@ name: Tester Release -# Builds the "tester" variant (debug-signed, but without the debug flag or any debug-only features) +# Builds the "beta" variant (debug-signed, but without the debug flag or any debug-only features) # and publishes it as a GitHub pre-release, so it can be sent to someone to try out. # Run it from the branch to test: Actions > Tester Release > Run workflow. # Note: the "Delete pre-releases" workflow removes these along with every other pre-release. @@ -30,8 +30,8 @@ jobs: - uses: gradle/actions/setup-gradle@v4 - - name: Build the tester APK - run: ./gradlew assembleTester + - name: Build the beta APK + run: ./gradlew assembleBeta - name: Publish the pre-release env: @@ -39,7 +39,7 @@ jobs: NOTES: ${{ inputs.notes }} run: | tag="tester-${{ github.run_number }}" - gh release create "$tag" app/build/outputs/apk/tester/app-tester.apk \ + gh release create "$tag" app/build/outputs/apk/beta/app-beta.apk \ --prerelease \ --target "$GITHUB_SHA" \ --title "Tester build ${{ github.run_number }} (${GITHUB_REF_NAME})" \ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f7898bb1..d4dc6efc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,13 +43,13 @@ android { applicationIdSuffix = ".dbg" signingConfig = signingConfigs.getByName("debugConfig") } - // For sending a build to testers: signed with the debug keystore, so it installs without the + // For sending a build to testers (not named "tester": build type names cannot start with "test"): signed with the debug keystore, so it installs without the // official key, but with BuildConfig.DEBUG false, so none of the debug-only features // (debug settings, fake SD card, extra logging) are in it. Installs next to the other builds. - create("tester") { + create("beta") { initWith(getByName("debug")) isDebuggable = false - applicationIdSuffix = ".tester" + applicationIdSuffix = ".beta" matchingFallbacks += listOf("release", "debug") } } diff --git a/docs/Release.md b/docs/Release.md index fc375a2d..bc29ef62 100644 --- a/docs/Release.md +++ b/docs/Release.md @@ -11,7 +11,7 @@ You're done! The F-Droid CI should now handle publishing a new release automatic ## Tester builds To send a build to someone to try out, run the "Tester Release" workflow (Actions > Tester Release > Run workflow) from the branch to test. -It builds the `tester` build type: signed with the debug keystore, with the application ID suffix `.tester`, and with `BuildConfig.DEBUG` false, +It builds the `beta` build type: signed with the debug keystore, with the application ID suffix `.beta`, and with `BuildConfig.DEBUG` false, so it has none of the debug-only features (debug settings, fake SD card, verbose logging). It publishes the APK as a GitHub pre-release, -which the "Delete pre-releases" workflow removes with the others. Locally: `./gradlew assembleTester`. +which the "Delete pre-releases" workflow removes with the others. Locally: `./gradlew assembleBeta`. From 630b01db7b3c902e8b4d8f0c88aa311c3b94517e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:23:41 +0000 Subject: [PATCH 14/17] Give the beta build access to the logs and crashes Adds BuildConfig.DEBUG_KIT_ENABLED (true in debug and beta, false in release). It turns on the shared library's logger and crash handler saving to disk, and shows Logs and Crashes cards in Settings for builds without debug settings, so testers can send them back. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FBqFYw2zBG9C9sT7ABYdMN --- CLAUDE.md | 3 +++ app/build.gradle.kts | 12 +++++++++--- .../java/com/lukeneedham/videodiary/App.kt | 6 +++--- .../ui/feature/settings/SettingsHubPage.kt | 4 ++++ .../feature/settings/SettingsHubPageContent.kt | 18 ++++++++++++++++++ .../ui/navigation/normal/NormalRouter.kt | 6 ++++++ docs/Release.md | 3 ++- 7 files changed, 45 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b2a4e55..3439eb82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,6 +163,9 @@ docs: `docs/using-the-library.md` there). The app has no logger of its own. `RemovableStorageMonitor`), `RootState.SdCardMissing` replaces the whole app with `SdCardMissingPage`; back closes the app. In debug builds that page has a "Reinsert SD card (debug)" button that turns the fake SD card on and inserts it. Mock entries are populated by copying the `sample_video` raw resource in as each video file. +- `BuildConfig.DEBUG_KIT_ENABLED` (true in `debug` and `beta`, false in `release`) controls whether the library's logger and crash handler save to disk + (`DebugKitConfig(enabled = ...)` in `App.onCreate`) and whether Settings shows "Logs" and "Crashes" cards when `DEBUG` is false. The `beta` build type + (debug keystore, `DEBUG` false, built by the "Tester Release" workflow) therefore has no debug settings or fake SD card, but testers can open the logs and crashes. - `app/src/debug/res/drawable/preview_video` is a large placeholder image used by Composable Previews for video players; it's excluded from release builds. A small fallback `preview_video` exists in the main `drawable` folder so release builds still compile. See [docs/Screenshots.md](docs/Screenshots.md). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d4dc6efc..2195c9f6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,6 +33,9 @@ android { buildTypes { release { + // Whether the shared library's logger and crash handler save logs and crashes to disk, + // and the Logs and Crashes pages can be opened. Off in release builds. + buildConfigField("boolean", "DEBUG_KIT_ENABLED", "false") isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -40,15 +43,18 @@ android { ) } debug { + buildConfigField("boolean", "DEBUG_KIT_ENABLED", "true") applicationIdSuffix = ".dbg" signingConfig = signingConfigs.getByName("debugConfig") } - // For sending a build to testers (not named "tester": build type names cannot start with "test"): signed with the debug keystore, so it installs without the - // official key, but with BuildConfig.DEBUG false, so none of the debug-only features - // (debug settings, fake SD card, extra logging) are in it. Installs next to the other builds. + // For sending a build to testers (not named "tester": build type names cannot start with "test"). + // Signed with the debug keystore, so it installs without the official key. BuildConfig.DEBUG is + // false, so none of the debug-only features are in it (debug settings, fake SD card), but the + // Logs and Crashes pages are, so testers can send them back. Installs next to the other builds. create("beta") { initWith(getByName("debug")) isDebuggable = false + buildConfigField("boolean", "DEBUG_KIT_ENABLED", "true") applicationIdSuffix = ".beta" matchingFallbacks += listOf("release", "debug") } diff --git a/app/src/main/java/com/lukeneedham/videodiary/App.kt b/app/src/main/java/com/lukeneedham/videodiary/App.kt index 20308e18..91f09ffc 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/App.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/App.kt @@ -21,9 +21,9 @@ class App : Application() { // Fatal crashes are saved to disk and shown on the Debug > Crashes page. DebugKit.init( DebugKitConfig( - enabled = BuildConfig.DEBUG, - // Debug logs are only kept in debug builds - minLogLevel = if (BuildConfig.DEBUG) LogLevel.Debug else LogLevel.Info, + enabled = BuildConfig.DEBUG_KIT_ENABLED, + // Debug logs are only kept in builds that save logs + minLogLevel = if (BuildConfig.DEBUG_KIT_ENABLED) LogLevel.Debug else LogLevel.Info, ), ) diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPage.kt index 46c885e6..b30f705e 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPage.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPage.kt @@ -7,10 +7,14 @@ fun SettingsHubPage( onMenuClick: () -> Unit, onVideoStorageClick: () -> Unit, onDebugClick: () -> Unit, + onLogsClick: () -> Unit, + onCrashesClick: () -> Unit, ) { SettingsHubPageContent( onMenuClick = onMenuClick, onVideoStorageClick = onVideoStorageClick, onDebugClick = onDebugClick, + onLogsClick = onLogsClick, + onCrashesClick = onCrashesClick, ) } diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPageContent.kt index cfde4217..222d0c7c 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPageContent.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/settings/SettingsHubPageContent.kt @@ -23,6 +23,8 @@ fun SettingsHubPageContent( onMenuClick: () -> Unit, onVideoStorageClick: () -> Unit, onDebugClick: () -> Unit, + onLogsClick: () -> Unit, + onCrashesClick: () -> Unit, ) { ToolbarPageLayout( bottomBar = { HubToolbar(onMenuClick = onMenuClick) }, @@ -47,6 +49,20 @@ fun SettingsHubPageContent( onClick = onDebugClick, ) } + // Debug builds reach these through the Debug settings. Beta builds have no debug + // settings, but testers still need to be able to send these back. + if (BuildConfig.DEBUG_KIT_ENABLED && !BuildConfig.DEBUG) { + SettingsCard( + title = "Logs", + iconRes = R.drawable.info, + onClick = onLogsClick, + ) + SettingsCard( + title = "Crashes", + iconRes = R.drawable.bug, + onClick = onCrashesClick, + ) + } } } } @@ -58,5 +74,7 @@ internal fun PreviewSettingsHubPageContent() { onMenuClick = {}, onVideoStorageClick = {}, onDebugClick = {}, + onLogsClick = {}, + onCrashesClick = {}, ) } diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalRouter.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalRouter.kt index 896deb37..bb8099a2 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalRouter.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/normal/NormalRouter.kt @@ -247,6 +247,12 @@ fun NormalRouter( onDebugClick = { navigate(NormalPage.Debug) }, + onLogsClick = { + navigate(NormalPage.Logs) + }, + onCrashesClick = { + navigate(NormalPage.Crashes) + }, ) is NormalPage.VideoStorage -> VideoStoragePage( diff --git a/docs/Release.md b/docs/Release.md index bc29ef62..7cd1092c 100644 --- a/docs/Release.md +++ b/docs/Release.md @@ -12,6 +12,7 @@ You're done! The F-Droid CI should now handle publishing a new release automatic To send a build to someone to try out, run the "Tester Release" workflow (Actions > Tester Release > Run workflow) from the branch to test. It builds the `beta` build type: signed with the debug keystore, with the application ID suffix `.beta`, and with `BuildConfig.DEBUG` false, -so it has none of the debug-only features (debug settings, fake SD card, verbose logging). It publishes the APK as a GitHub pre-release, +so it has none of the debug-only features (debug settings, fake SD card). It does save logs and crashes, with "Logs" and "Crashes" entries in Settings +so testers can send them back (`BuildConfig.DEBUG_KIT_ENABLED`, true in debug and beta builds, false in release builds). It publishes the APK as a GitHub pre-release, which the "Delete pre-releases" workflow removes with the others. Locally: `./gradlew assembleBeta`. From f35e541027a5d31f2f9956788fd82549fed79eba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:26:06 +0000 Subject: [PATCH 15/17] Let the Tester Release workflow be started by a PR label The workflow is not on main yet, so it cannot be started from the Actions tab or the API. Adding the tester-build label to a pull request now builds that pull request's branch. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FBqFYw2zBG9C9sT7ABYdMN --- .github/workflows/tester_release.yml | 19 +++++++++++++++---- docs/Release.md | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tester_release.yml b/.github/workflows/tester_release.yml index 02b357c2..46bc2c16 100644 --- a/.github/workflows/tester_release.yml +++ b/.github/workflows/tester_release.yml @@ -2,10 +2,15 @@ name: Tester Release # Builds the "beta" variant (debug-signed, but without the debug flag or any debug-only features) # and publishes it as a GitHub pre-release, so it can be sent to someone to try out. -# Run it from the branch to test: Actions > Tester Release > Run workflow. +# Two ways to run it: +# - Add the "tester-build" label to a pull request: builds that pull request's branch. Works before +# the workflow is on main. Remove and re-add the label to build again. +# - Actions > Tester Release > Run workflow: builds the branch you pick (once this file is on main). # Note: the "Delete pre-releases" workflow removes these along with every other pre-release. on: + pull_request: + types: [labeled] workflow_dispatch: inputs: notes: @@ -19,9 +24,13 @@ permissions: jobs: tester: + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'tester-build' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + # For a pull request, build the branch itself, not its merge with main + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-java@v4 with: @@ -37,10 +46,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} NOTES: ${{ inputs.notes }} + BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + BUILD_REF_NAME: ${{ github.event.pull_request.head.ref || github.ref_name }} run: | tag="tester-${{ github.run_number }}" gh release create "$tag" app/build/outputs/apk/beta/app-beta.apk \ --prerelease \ - --target "$GITHUB_SHA" \ - --title "Tester build ${{ github.run_number }} (${GITHUB_REF_NAME})" \ - --notes "${NOTES:-Tester build of ${GITHUB_REF_NAME} at ${GITHUB_SHA::7}.}" + --target "$BUILD_SHA" \ + --title "Tester build ${{ github.run_number }} (${BUILD_REF_NAME})" \ + --notes "${NOTES:-Tester build of ${BUILD_REF_NAME} at ${BUILD_SHA::7}.}" diff --git a/docs/Release.md b/docs/Release.md index 7cd1092c..7978a078 100644 --- a/docs/Release.md +++ b/docs/Release.md @@ -10,7 +10,7 @@ You're done! The F-Droid CI should now handle publishing a new release automatic ## Tester builds -To send a build to someone to try out, run the "Tester Release" workflow (Actions > Tester Release > Run workflow) from the branch to test. +To send a build to someone to try out, run the "Tester Release" workflow: add the `tester-build` label to the pull request to build its branch, or use Actions > Tester Release > Run workflow once the workflow is on `main`. It builds the `beta` build type: signed with the debug keystore, with the application ID suffix `.beta`, and with `BuildConfig.DEBUG` false, so it has none of the debug-only features (debug settings, fake SD card). It does save logs and crashes, with "Logs" and "Crashes" entries in Settings so testers can send them back (`BuildConfig.DEBUG_KIT_ENABLED`, true in debug and beta builds, false in release builds). It publishes the APK as a GitHub pre-release, From 0036a6fe4f33fb65e628f72fab5a4c5066751891 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:46:05 +0000 Subject: [PATCH 16/17] Add a video storage page to onboarding when an SD card is detected After the orientation page, onboarding asks whether to keep videos in internal storage or on the SD card. The page is only part of the flow when an SD card is available as onboarding starts. The fake SD card is now on by default in debug builds so the page can be tried without a real card. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FBqFYw2zBG9C9sT7ABYdMN --- CLAUDE.md | 4 +- .../data/persistence/SettingsDao.kt | 7 +- .../videodiary/data/persistence/VideosDao.kt | 2 +- .../lukeneedham/videodiary/di/KoinModule.kt | 7 ++ .../ui/feature/debug/DebugViewModel.kt | 2 +- .../setup/storage/SetupSelectStoragePage.kt | 23 ++++ .../storage/SetupSelectStoragePageContent.kt | 117 ++++++++++++++++++ .../storage/SetupSelectStorageViewModel.kt | 57 +++++++++ .../ui/navigation/setup/SetupProgress.kt | 25 ++-- .../ui/navigation/setup/SetupRouter.kt | 48 ++++--- 10 files changed, 263 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/lukeneedham/videodiary/ui/feature/setup/storage/SetupSelectStoragePage.kt create mode 100644 app/src/main/java/com/lukeneedham/videodiary/ui/feature/setup/storage/SetupSelectStoragePageContent.kt create mode 100644 app/src/main/java/com/lukeneedham/videodiary/ui/feature/setup/storage/SetupSelectStorageViewModel.kt diff --git a/CLAUDE.md b/CLAUDE.md index 3439eb82..45854851 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,7 @@ All DI wiring lives in `di/KoinModule.kt`, grouped into module functions: ### Navigation - Two navigation graphs, each with a sealed `Page` class and a `Router` composable: - - `ui/navigation/setup/` (`SetupPage`, `SetupRouter`) — first-run setup flow (intro → orientation → resolution → video duration) + - `ui/navigation/setup/` (`SetupPage`, `SetupRouter`) — first-run setup flow (intro → permissions → orientation → video storage → resolution → video duration). The video storage page is only part of the flow when an SD card is detected as onboarding starts (`SetupProgress(hasStoragePage)`) - `ui/navigation/normal/` (`NormalPage`, `NormalRouter`) — main app flow (calendar, record video, check video, export diary, debug, logs, crashes) - `ui/root/Root.kt` + `RootViewModel`/`RootState` decide which graph (or permissions screen / loading spinner) to show, based on permission state and whether setup has completed. @@ -156,7 +156,7 @@ docs: `docs/using-the-library.md` there). The app has no logger of its own. `ProvideDebugSettings` is in `MainActivity`): the recap share sheet settle (`RecapViewPageContent`) and the camera focus indicator hold and fade (`CameraInput`). Give any new animation an explicit duration and pass it through `scale`. Animations that use the default spring (`SingleOptionSelector`, the orientation text colour) are not affected by the setting. - The Debug page's Storage section fakes an SD card for testing Video storage on a phone without one: "Fake SD card" swaps the removable + The Debug page's Storage section fakes an SD card for testing Video storage on a phone without one: "Fake SD card" (on by default in debug builds, so onboarding offers the SD card without a real card) swaps the removable storage folder for `filesDir/fake_sd_card/videos`, "Fake SD card inserted" simulates removing and re-inserting it, and "Slow video moves" adds an 8 second pause to a move so it can be interrupted (all `BuildConfig.DEBUG` only, applied in `VideosDao`). When the videos are stored on the SD card and it is missing (checked on launch, on every resume, and on storage broadcasts via diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt index 1ad34817..21673fd3 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/SettingsDao.kt @@ -43,7 +43,10 @@ class SettingsDao( private val videoStorageLocationKey = stringPreferencesKey("videoStorageLocation") - /** Debug setting: pretend there is an SD card, stored in a folder inside the app's files */ + /** + * Debug setting: pretend there is an SD card, stored in a folder inside the app's files. + * On by default, so onboarding offers the SD card choice in debug builds without a real card. + */ private val debugFakeSdCardEnabled = booleanPreferencesKey("debugFakeSdCardEnabled") /** Debug setting: whether the fake SD card is currently inserted */ @@ -151,7 +154,7 @@ class SettingsDao( } fun getDebugFakeSdCardEnabledFlow(): Flow { - return context.dataStore.data.map { it[debugFakeSdCardEnabled] ?: false } + return context.dataStore.data.map { it[debugFakeSdCardEnabled] ?: true } } suspend fun setDebugFakeSdCardInserted(inserted: Boolean) { diff --git a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt index fcee83f9..ba44736e 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/data/persistence/VideosDao.kt @@ -41,7 +41,7 @@ class VideosDao( // Debug-only (ignored in release builds), loaded from the settings by initialiseStorageLocation @Volatile - private var debugFakeSdCardEnabled = false + private var debugFakeSdCardEnabled = true @Volatile private var debugFakeSdCardInserted = true @Volatile diff --git a/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt b/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt index 15e81e76..6fc18cb7 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt @@ -32,6 +32,7 @@ import com.lukeneedham.videodiary.ui.feature.record.film.RecordVideoViewModel import com.lukeneedham.videodiary.ui.feature.setup.duration.SelectVideoDurationViewModel import com.lukeneedham.videodiary.ui.feature.setup.orientation.SetupSelectOrientationViewModel import com.lukeneedham.videodiary.ui.feature.setup.resolution.SetupSelectResolutionViewModel +import com.lukeneedham.videodiary.ui.feature.setup.storage.SetupSelectStorageViewModel import com.lukeneedham.videodiary.ui.media.VideoPlayerHolder import com.lukeneedham.videodiary.ui.root.RootViewModel import com.lukeneedham.videodiary.ui.share.Sharer @@ -157,6 +158,12 @@ object KoinModule { ioDispatcher = get(KoinQualifier.Dispatcher.io), ) } + viewModel { + SetupSelectStorageViewModel( + videosDao = get(), + ioDispatcher = get(KoinQualifier.Dispatcher.io), + ) + } viewModel { SelectVideoDurationViewModel( settingsDao = get(), diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugViewModel.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugViewModel.kt index 49a5e319..a493bf5f 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugViewModel.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/debug/DebugViewModel.kt @@ -22,7 +22,7 @@ class DebugViewModel( .stateIn(viewModelScope, SharingStarted.Eagerly, false) val fakeSdCardEnabled: StateFlow = settingsDao.getDebugFakeSdCardEnabledFlow() - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + .stateIn(viewModelScope, SharingStarted.Eagerly, true) val fakeSdCardInserted: StateFlow = settingsDao.getDebugFakeSdCardInsertedFlow() .stateIn(viewModelScope, SharingStarted.Eagerly, true) diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/setup/storage/SetupSelectStoragePage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/setup/storage/SetupSelectStoragePage.kt new file mode 100644 index 00000000..e0a02323 --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/setup/storage/SetupSelectStoragePage.kt @@ -0,0 +1,23 @@ +package com.lukeneedham.videodiary.ui.feature.setup.storage + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +@Composable +fun SetupSelectStoragePage( + viewModel: SetupSelectStorageViewModel, + onContinue: () -> Unit, +) { + LaunchedEffect(Unit) { + viewModel.onSavedEventFlow.collect { + onContinue() + } + } + + SetupSelectStoragePageContent( + options = viewModel.options, + selectedOption = viewModel.location, + setSelectedOption = viewModel::onLocationChange, + error = viewModel.error, + ) +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/setup/storage/SetupSelectStoragePageContent.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/setup/storage/SetupSelectStoragePageContent.kt new file mode 100644 index 00000000..c4ba461d --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/setup/storage/SetupSelectStoragePageContent.kt @@ -0,0 +1,117 @@ +package com.lukeneedham.videodiary.ui.feature.setup.storage + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.lukeneedham.videodiary.R +import com.lukeneedham.videodiary.domain.model.VideoStorageLocation +import com.lukeneedham.videodiary.ui.feature.common.SingleOptionSelector +import com.lukeneedham.videodiary.ui.feature.setup.SetupStepHeader +import com.lukeneedham.videodiary.ui.theme.AccentRecord +import com.lukeneedham.videodiary.ui.theme.Typography + +@Composable +fun SetupSelectStoragePageContent( + options: List, + selectedOption: VideoStorageLocation, + setSelectedOption: (VideoStorageLocation) -> Unit, + error: String?, +) { + Column( + modifier = Modifier.fillMaxSize() + ) { + SetupStepHeader( + iconRes = R.drawable.movie, + title = "Where should your videos be stored?", + description = "Videos can take up a lot of space. You can change this later in Settings.", + modifier = Modifier.padding(top = 20.dp), + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 30.dp), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SingleOptionSelector( + options = options, + selectedOption = selectedOption, + setSelectedOption = setSelectedOption, + modifier = Modifier.fillMaxWidth(), + ) { option, isSelected -> + val text = when (option) { + VideoStorageLocation.Internal -> "Internal storage" + VideoStorageLocation.RemovableStorage -> "SD card" + } + + val targetTextColor = if (isSelected) Color.White else Color.DarkGray + val textColor by animateColorAsState( + targetValue = targetTextColor, + label = "Animate storage text color" + ) + Text( + text = text, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = textColor, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + val explanation = when (selectedOption) { + VideoStorageLocation.Internal -> + "Your videos stay in the app's private storage on your phone." + + VideoStorageLocation.RemovableStorage -> + "Your videos are kept in this app's folder on your SD card. " + + "The SD card must be in your phone to use the app, and the videos " + + "are deleted if the app is uninstalled." + } + Text( + text = explanation, + color = Color.Gray, + fontSize = Typography.Size.extraSmall, + textAlign = TextAlign.Center, + ) + + if (error != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = error, + color = AccentRecord, + fontSize = Typography.Size.extraSmall, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +@Preview +@Composable +internal fun PreviewSetupSelectStoragePageContent() { + SetupSelectStoragePageContent( + options = listOf(VideoStorageLocation.Internal, VideoStorageLocation.RemovableStorage), + selectedOption = VideoStorageLocation.RemovableStorage, + setSelectedOption = {}, + error = null, + ) +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/setup/storage/SetupSelectStorageViewModel.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/setup/storage/SetupSelectStorageViewModel.kt new file mode 100644 index 00000000..d568f0f3 --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/setup/storage/SetupSelectStorageViewModel.kt @@ -0,0 +1,57 @@ +package com.lukeneedham.videodiary.ui.feature.setup.storage + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.lukeneedham.videodiary.data.persistence.VideosDao +import com.lukeneedham.videodiary.domain.model.VideoStorageLocation +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +class SetupSelectStorageViewModel( + private val videosDao: VideosDao, + ioDispatcher: CoroutineDispatcher, +) : ViewModel() { + private val saveSettingsScope = CoroutineScope(ioDispatcher) + + private val onSavedEventMutable = MutableSharedFlow( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + extraBufferCapacity = 1, + ) + val onSavedEventFlow = onSavedEventMutable.asSharedFlow() + + /** + * Whether there is an SD card to choose. Read once, when onboarding starts: the storage page + * is only part of onboarding if this is true, and the pages must not change part way through. + */ + val isRemovableStorageAvailable: Boolean = videosDao.isRemovableStorageAvailable() + + var location: VideoStorageLocation by mutableStateOf(VideoStorageLocation.Internal) + private set + + var error: String? by mutableStateOf(null) + private set + + val options = listOf(VideoStorageLocation.Internal, VideoStorageLocation.RemovableStorage) + + fun onLocationChange(newLocation: VideoStorageLocation) { + location = newLocation + error = null + } + + fun saveSettings() { + saveSettingsScope.launch { + val result = videosDao.moveVideosTo(location) + if (result.isSuccess) { + onSavedEventMutable.emit(Unit) + } else { + error = "Could not use the SD card. Check that it is inserted, or choose internal storage." + } + } + } +} diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/setup/SetupProgress.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/setup/SetupProgress.kt index 8c932fda..387c4167 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/setup/SetupProgress.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/setup/SetupProgress.kt @@ -6,17 +6,28 @@ import com.lukeneedham.videodiary.ui.permissions.RequiredPermissions * The whole onboarding flow (intro slides, permission requests, and the setup wizard steps that * follow) is presented as one continuous sequence of pages, so a single page indicator can track * progress across all of it. + * + * The storage location page is only part of the sequence when [hasStoragePage]: it is only useful + * when there is an SD card to choose. */ -object SetupProgress { - const val INTRO_SLIDE_COUNT = 4 - +class SetupProgress(hasStoragePage: Boolean) { val PERMISSION_COUNT = RequiredPermissions.permissions.size val PERMISSIONS_START_INDEX = INTRO_SLIDE_COUNT val ORIENTATION_PAGE_INDEX = INTRO_SLIDE_COUNT + PERMISSION_COUNT - val RESOLUTION_PAGE_INDEX = ORIENTATION_PAGE_INDEX + 1 - val DURATION_PAGE_INDEX = ORIENTATION_PAGE_INDEX + 2 - val FINAL_PAGE_INDEX = ORIENTATION_PAGE_INDEX + 3 - val TOTAL_PAGE_COUNT = ORIENTATION_PAGE_INDEX + 4 + /** Right after the orientation page, or -1 when there is no storage page */ + val STORAGE_PAGE_INDEX = if (hasStoragePage) ORIENTATION_PAGE_INDEX + 1 else -1 + + private val afterStorageIndex = ORIENTATION_PAGE_INDEX + if (hasStoragePage) 2 else 1 + + val RESOLUTION_PAGE_INDEX = afterStorageIndex + val DURATION_PAGE_INDEX = afterStorageIndex + 1 + val FINAL_PAGE_INDEX = afterStorageIndex + 2 + + val TOTAL_PAGE_COUNT = afterStorageIndex + 3 + + companion object { + const val INTRO_SLIDE_COUNT = 4 + } } diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/setup/SetupRouter.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/setup/SetupRouter.kt index 0817f5fa..6db135e5 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/setup/SetupRouter.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/navigation/setup/SetupRouter.kt @@ -46,12 +46,14 @@ import com.lukeneedham.videodiary.ui.feature.setup.orientation.SetupSelectOrient import com.lukeneedham.videodiary.ui.feature.setup.orientation.SetupSelectOrientationViewModel import com.lukeneedham.videodiary.ui.feature.setup.resolution.SetupSelectResolutionPage import com.lukeneedham.videodiary.ui.feature.setup.resolution.SetupSelectResolutionViewModel +import com.lukeneedham.videodiary.ui.feature.setup.storage.SetupSelectStoragePage +import com.lukeneedham.videodiary.ui.feature.setup.storage.SetupSelectStorageViewModel import com.lukeneedham.videodiary.ui.permissions.PermissionResultListenerHolder import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel /** - * The whole onboarding flow (intro, permissions, orientation, resolution, video duration) is one + * The whole onboarding flow (intro, permissions, orientation, video storage (only if there is an SD card), resolution, video duration) is one * continuous swipeable pager. Pages are only revealed as their prerequisites are met: permission * pages reveal one at a time as each is granted, and the setup pages that follow (which rely on * the camera) aren't revealed until every permission is granted. @@ -77,6 +79,10 @@ fun SetupRouter( val orientationViewModel: SetupSelectOrientationViewModel = koinViewModel() val resolutionViewModel: SetupSelectResolutionViewModel = koinViewModel() val durationViewModel: SelectVideoDurationViewModel = koinViewModel() + val storageViewModel: SetupSelectStorageViewModel = koinViewModel() + + // The storage page is only there if there is an SD card to choose when onboarding starts + val progress = remember { SetupProgress(hasStoragePage = storageViewModel.isRemovableStorageAvailable) } LaunchedEffect(permissionResultListenerHolder, permissionsViewModel) { permissionResultListenerHolder.onPermissionResult = permissionsViewModel::onPermissionResult @@ -138,9 +144,9 @@ fun SetupRouter( // Pages beyond the current prerequisite aren't revealed yet, so the user can't swipe past a // permission without granting it, or past resolution selection without a valid resolution. val revealedPageCount = when { - !allPermissionsGranted -> SetupProgress.PERMISSIONS_START_INDEX + firstMissingPermissionIndex + 1 - !isResolutionReady -> SetupProgress.RESOLUTION_PAGE_INDEX + 1 - else -> SetupProgress.TOTAL_PAGE_COUNT + !allPermissionsGranted -> progress.PERMISSIONS_START_INDEX + firstMissingPermissionIndex + 1 + !isResolutionReady -> progress.RESOLUTION_PAGE_INDEX + 1 + else -> progress.TOTAL_PAGE_COUNT } val pagerState = rememberPagerState { revealedPageCount } @@ -159,8 +165,8 @@ fun SetupRouter( onClick = ::goToNextPage, ) - currentPage < SetupProgress.ORIENTATION_PAGE_INDEX -> { - val permission = requiredPermissions.getOrNull(currentPage - SetupProgress.PERMISSIONS_START_INDEX) + currentPage < progress.ORIENTATION_PAGE_INDEX -> { + val permission = requiredPermissions.getOrNull(currentPage - progress.PERMISSIONS_START_INDEX) when { permission == null -> null permission.permission in acquiredPermissions -> PagerAction( @@ -174,12 +180,17 @@ fun SetupRouter( } } - currentPage == SetupProgress.ORIENTATION_PAGE_INDEX -> PagerAction( + currentPage == progress.ORIENTATION_PAGE_INDEX -> PagerAction( label = "Next", onClick = { orientationViewModel.saveSettings() }, ) - currentPage == SetupProgress.RESOLUTION_PAGE_INDEX -> { + currentPage == progress.STORAGE_PAGE_INDEX -> PagerAction( + label = "Next", + onClick = { storageViewModel.saveSettings() }, + ) + + currentPage == progress.RESOLUTION_PAGE_INDEX -> { val rotationLocal = resolutionRotation PagerAction( label = "Next", @@ -192,12 +203,12 @@ fun SetupRouter( ) } - currentPage == SetupProgress.DURATION_PAGE_INDEX -> PagerAction( + currentPage == progress.DURATION_PAGE_INDEX -> PagerAction( label = "Next", onClick = { durationViewModel.saveSettings() }, ) - currentPage == SetupProgress.FINAL_PAGE_INDEX -> PagerAction( + currentPage == progress.FINAL_PAGE_INDEX -> PagerAction( label = "Get started", onClick = onSetupComplete, ) @@ -217,21 +228,26 @@ fun SetupRouter( slide = setupIntroSlides[page], ) - page < SetupProgress.ORIENTATION_PAGE_INDEX -> { - val permission = requiredPermissions[page - SetupProgress.PERMISSIONS_START_INDEX] + page < progress.ORIENTATION_PAGE_INDEX -> { + val permission = requiredPermissions[page - progress.PERMISSIONS_START_INDEX] RequestPermissionPage( permission = permission, isGranted = permission.permission in acquiredPermissions, ) } - page == SetupProgress.ORIENTATION_PAGE_INDEX -> SetupSelectOrientationPage( + page == progress.ORIENTATION_PAGE_INDEX -> SetupSelectOrientationPage( viewModel = orientationViewModel, onContinue = ::goToNextPage, setOrientation = setOrientation, ) - page == SetupProgress.RESOLUTION_PAGE_INDEX -> SetupSelectResolutionPage( + page == progress.STORAGE_PAGE_INDEX -> SetupSelectStoragePage( + viewModel = storageViewModel, + onContinue = ::goToNextPage, + ) + + page == progress.RESOLUTION_PAGE_INDEX -> SetupSelectResolutionPage( viewModel = resolutionViewModel, onContinue = ::goToNextPage, resolutions = resolutions, @@ -244,7 +260,7 @@ fun SetupRouter( }, ) - page == SetupProgress.DURATION_PAGE_INDEX -> SelectVideoDurationPage( + page == progress.DURATION_PAGE_INDEX -> SelectVideoDurationPage( viewModel = durationViewModel, onContinue = ::goToNextPage, ) @@ -260,7 +276,7 @@ fun SetupRouter( .padding(vertical = 10.dp), ) { PageIndicator( - pageCount = SetupProgress.TOTAL_PAGE_COUNT, + pageCount = progress.TOTAL_PAGE_COUNT, currentPageIndex = currentPage, color = Color.Black, ) From e8c865e58a16207d0d3316ed1d7d17921b388d4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:46:27 +0000 Subject: [PATCH 17/17] Name the beta build 'Video Diary - Beta' Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FBqFYw2zBG9C9sT7ABYdMN --- app/src/beta/res/values/strings.xml | 3 +++ docs/Release.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 app/src/beta/res/values/strings.xml diff --git a/app/src/beta/res/values/strings.xml b/app/src/beta/res/values/strings.xml new file mode 100644 index 00000000..f47c966e --- /dev/null +++ b/app/src/beta/res/values/strings.xml @@ -0,0 +1,3 @@ + + Video Diary - Beta + diff --git a/docs/Release.md b/docs/Release.md index 7978a078..b39e8cac 100644 --- a/docs/Release.md +++ b/docs/Release.md @@ -15,4 +15,4 @@ It builds the `beta` build type: signed with the debug keystore, with the applic so it has none of the debug-only features (debug settings, fake SD card). It does save logs and crashes, with "Logs" and "Crashes" entries in Settings so testers can send them back (`BuildConfig.DEBUG_KIT_ENABLED`, true in debug and beta builds, false in release builds). It publishes the APK as a GitHub pre-release, which the "Delete pre-releases" workflow removes with the others. Locally: `./gradlew assembleBeta`. - +Its launcher name is "Video Diary - Beta" (`app/src/beta/res`), like "Video Diary - Debug" for debug builds, so it is easy to tell apart from the real app.