Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<VideoStorageLocation> {
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<Boolean> {
return context.dataStore.data.map { it[debugFakeSdCardEnabled] ?: false }
}

suspend fun setDebugFakeSdCardInserted(inserted: Boolean) {
updatePrefs { set(debugFakeSdCardInserted, inserted) }
}

fun getDebugFakeSdCardInsertedFlow(): Flow<Boolean> {
return context.dataStore.data.map { it[debugFakeSdCardInserted] ?: true }
}

suspend fun setDebugSlowVideoMoves(slow: Boolean) {
updatePrefs { set(debugSlowVideoMoves, slow) }
}

fun getDebugSlowVideoMovesFlow(): Flow<Boolean> {
return context.dataStore.data.map { it[debugSlowVideoMoves] ?: false }
}

private suspend fun getPrefs() = context.dataStore.data.first()

private suspend fun updatePrefs(block: MutablePreferences.() -> Unit) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<File>,
private val created: List<File>,
private val sourceSnapshot: Map<String, FileState>,
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<File> = 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<String, FileState> {
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<File>()
val created = mutableListOf<File>()
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<File> {
return dir.listFiles { file ->
file.isFile && !file.name.endsWith(TEMPORARY_SUFFIX)
}?.toList().orEmpty()
}
}
}
Loading
Loading