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 6969dbf3..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 @@ -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,17 @@ 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") + + /** 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) @@ -112,6 +124,52 @@ class SettingsDao( } } + suspend fun setVideoStorageLocation(location: VideoStorageLocation) { + updatePrefs { + when (location) { + VideoStorageLocation.Internal -> { + set(videoStorageLocationKey, "Internal") + } + VideoStorageLocation.RemovableStorage -> { + set(videoStorageLocationKey, "RemovableStorage") + } + } + } + } + + fun getVideoStorageLocationFlow(): Flow { + return context.dataStore.data.map { prefs -> + when (prefs[videoStorageLocationKey]) { + "RemovableStorage" -> VideoStorageLocation.RemovableStorage + else -> VideoStorageLocation.Internal + } + } + } + + 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/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 b0a94feb..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 @@ -2,39 +2,187 @@ 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.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 +import kotlinx.coroutines.withContext 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 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() + + // 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() } 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() { + 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 + + // 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.w("Could not merge videos into $wantedDir", e) + } + } + 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 + + /** + * 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 + } + + val transfer = VideoFolderTransfer.copy( + from = sourceDir, + to = destinationDir, + failOnConflict = true, + ) + try { + if (BuildConfig.DEBUG && debugSlowVideoMoves) delay(DEBUG_SLOW_MOVE_MILLIS) + 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 + } + + usingFallbackStorageMutable.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") } + }.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() + 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() } @@ -61,13 +209,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 { @@ -85,14 +235,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() } @@ -127,7 +279,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 { @@ -151,6 +305,24 @@ class VideosDao( private fun getVideoFile(name: String) = File(videosDir, name) + private fun getVideosDir(location: VideoStorageLocation): File? = when (location) { + VideoStorageLocation.Internal -> internalVideosDir + 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) && + Environment.getExternalStorageState(directory) == Environment.MEDIA_MOUNTED + } + ?.let { File(it, "videos") } + } + private fun getVideoFileName(date: LocalDate) = videoFileNameMapper.dateToName(date) private fun getThumbnailFile(date: LocalDate) = @@ -163,6 +335,10 @@ class VideosDao( private fun loadAllVideos(): List { val files = videosDir.listFiles() ?: return emptyList() - return files.toList() + return files.filterNot { it.name.endsWith(VideoFolderTransfer.TEMPORARY_SUFFIX) } + } + + private companion object { + const val DEBUG_SLOW_MOVE_MILLIS = 8_000L } -} \ 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 9a97f0ab..7e0e6e14 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/di/KoinModule.kt @@ -20,6 +20,7 @@ import com.lukeneedham.videodiary.ui.feature.common.datepicker.DiaryDatePickerVi 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 @@ -74,6 +75,7 @@ object KoinModule { single { VideosDao( context = androidContext(), + settingsDao = get(), videoFileNameMapper = get(), thumbnailFileNameMapper = get(), videoThumbnailExtractor = get(), @@ -174,6 +176,13 @@ object KoinModule { settingsDao = get(), ) } + viewModel { + VideoStorageViewModel( + settingsDao = get(), + videosDao = get(), + ioDispatcher = get(KoinQualifier.Dispatcher.io), + ) + } viewModel { RecapCreateCustomViewModel( 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..fa8274b1 --- /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. */ +sealed interface VideoStorageLocation { + data object Internal : VideoStorageLocation + data object RemovableStorage : VideoStorageLocation +} 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) { 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/VideoStoragePage.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt new file mode 100644 index 00000000..8caaabac --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePage.kt @@ -0,0 +1,26 @@ +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() + val isUsingFallbackStorage by viewModel.isUsingFallbackStorage.collectAsState() + VideoStoragePageContent( + location = location, + isRemovableStorageAvailable = isRemovableStorageAvailable, + isUsingFallbackStorage = isUsingFallbackStorage, + 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..2c13b97e --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStoragePageContent.kt @@ -0,0 +1,294 @@ +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 +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.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 +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 +fun VideoStoragePageContent( + location: VideoStorageLocation, + isRemovableStorageAvailable: Boolean, + isUsingFallbackStorage: Boolean, + isChangingLocation: Boolean, + error: String?, + onLocationSelected: (VideoStorageLocation) -> Unit, + canGoBack: Boolean, + onBack: () -> Unit, +) { + ToolbarPageLayout( + bottomBar = { SubpageToolbar(canGoBack = canGoBack, onBack = onBack) }, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(AppBackground) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 24.dp), + ) { + Text( + text = "Video storage", + color = Color.White, + fontSize = Typography.Size.big, + fontWeight = FontWeight.Bold, + ) + Text( + 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), + ) + + 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) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 24.dp, start = 4.dp), + ) { + CircularProgressIndicator( + color = AccentHighlight, + strokeWidth = 2.dp, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + 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) { + Notice(accent = AccentRecord, text = error) + } + } + } +} + +/** A titled group of rows, shown together in one rounded card. */ +@Composable +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(16.dp) + ) { + 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 = Color.White.copy(alpha = contentAlpha), + fontSize = Typography.Size.medium, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + 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), + ) + } +} + +@Preview +@Composable +private fun PreviewVideoStoragePageContent() { + VideoStoragePageContent( + location = VideoStorageLocation.Internal, + isRemovableStorageAvailable = true, + isUsingFallbackStorage = false, + isChangingLocation = false, + error = null, + onLocationSelected = {}, + canGoBack = true, + 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 = {}, + ) +} 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..cfc34001 --- /dev/null +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/storage/VideoStorageViewModel.kt @@ -0,0 +1,61 @@ +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( + 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()) + + 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 + + viewModelScope.launch { + isChangingLocation = true + error = null + val result = withContext(ioDispatcher) { + videosDao.moveVideosTo(location) + } + if (result.isFailure) { + 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 a383c919..ed2db6cd 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 f7d045db..896deb37 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 @@ -19,6 +19,7 @@ import com.lukeneedham.videodiary.ui.feature.common.hub.HubSwitcherSheet import com.lukeneedham.videodiary.ui.feature.common.sheet.SheetDefaults 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 @@ -240,11 +241,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, + ) + // The app's own debug page, which opens the shared library's debug page. The library's // debug, logs and crashes pages show only the library's own things. is NormalPage.Debug -> DebugPage( 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..22586c15 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 } } 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 + + 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()) + } +}