From 2bc0bd4b8fcb7dc95285459a6f323d1579f077ff Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 4 Aug 2026 21:22:26 +0300 Subject: [PATCH 01/14] Add AGENTS.md --- AGENTS.md | 430 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + GEMINI.md | 1 + 3 files changed, 432 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 120000 GEMINI.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1dfd8d9f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,430 @@ +# Agents Guidelines — Camera + +Shared guidelines for all AI coding agents working on GrapheneOS Camera. +`CLAUDE.md` and `GEMINI.md` are symlinks to this file — edit this one. + +--- + +## Project Overview + +Android camera app built on CameraX. Single `:app` module of app Kotlin plus vendored AndroidX +Java under `androidxc/` (do not modify or restyle). The app is migrating incrementally from +Views/XML to Compose. + +**This repository exists to raise PRs against upstream GrapheneOS Camera.** Every change must stand +on its own merits to a reviewer with no context beyond the diff. No big-bang rewrites. + +### Key Coordinates + +| Key | Value | +|-------------|-----------------------------------------------| +| Package | `app.grapheneos.camera` | +| minSdk | 29 — the one that constrains API choices | +| targetSdk | tracks compileSdk | +| Build types | `debug` (`.dev`), `release`, `play` (`.play`) | +| Toolchain | JDK 17 (CI runs Gradle itself on a newer JDK) | + +Versions live in `gradle/libs.versions.toml` and `gradle/wrapper/gradle-wrapper.properties`. Read +them there — they are the source of truth, and a number copied into this document is a number that +will be wrong. + +### Target Layout + +The current tree is flat Views-era code — read it, don't memorize it from here. **New code lands in +this shape**; anything extracted or rewritten moves toward it, never away: + +Each layer splits per feature, and each feature splits by role: + +``` +app/src/main/java/app/grapheneos/camera/ + data/ + settings/ + model/ CameraSettings, per-mode setting values + repository/ SettingsRepository (entry-mode-scoped, never application-scoped) + store/ prefs-backed stores, EphemeralSharedPrefs namespace + camera/ + model/ CameraCapabilities, lens/extension descriptors + repository/ CameraProviderSource + store/ ExtensionAvailabilityStore + media/ + model/ CapturedItem and friends + repository/ CapturedItemStore + store/ MediaStoreDataSource, SafDataSource + location/ + repository/ LocationRepository + domain/ + camera/usecase/ bind, rebind, lens/flash/zoom/focus + capture/usecase/ capture image, start/stop/pause recording + qr/usecase/ barcode scanning + gallery/usecase/ share, edit, delete (the guarded variants from CapturedItems.kt) + ui/ + core/ Theme.kt, Preview.kt + common/components/ composables shared across screens + viewfinder/ + screen/ ViewfinderScreen, ViewfinderViewModel, ViewfinderEffectHandler + model/ ViewfinderUiState, ViewfinderAction, ViewfinderScreenEffect, NavEvent + mapper/ domain → UiState mappers + components/ CaptureButton, ModeTabStrip, ZoomSlider, GridOverlay, FocusRing, ... + gallery/ same screen/{model,mapper} + components/ shape + videoplayer/ " + settings/ " (viewfinder settings sheet) + moresettings/ " + di/ + core/ app-wide modules, qualifiers + / one module package per feature (camera, capture, gallery, ...) +``` + +Roles: `model/` = plain data types, `repository/` = the feature's public data API, +`store/` = persistence/platform sources behind it, `mapper/` = pure transformation functions, +`usecase/` = one verb per class. A package appears when its first class does — don't pre-create +empty directories. + +`app/src/main/java/androidxc/` is vendored AndroidX Java — do not modify. + +### Activity entry points + +``` +MainActivity ← SecureMainActivity ← QrTile + ← VideoOnlyActivity + ← CaptureActivity ← SecureCaptureActivity + ← VideoCaptureActivity +``` + +Plus `InAppGallery`, `VideoPlayer`, `MoreSettings ← MoreSettingsSecure`, and the `CameraLauncher` +activity-alias. The inheritance chain is today's configuration mechanism — it is how each entry +point differs. Treat any change to it as a change to the manifest contract. + +--- + +## Build & Run + +```sh +./gradlew :app:compileDebugKotlin # fast check — run this after writing Kotlin +./gradlew :app:assembleDebug # debug APK +./gradlew build --no-daemon # what CI runs +./gradlew :app:dependencies # after touching build files — check what CameraX resolved to +``` + +**The debug build installs as `app.grapheneos.camera.dev`.** The plain `app.grapheneos.camera` +package is the stock system app that ships with the OS. Install with `./gradlew installDebug` and +verify against `.dev` — verifying against the stock package makes a working change look dead. + +--- + +## Testing + +| Suite | Location | Command | Device | +|------------------|------------------------|--------------------------------------------|:------:| +| **Instrumented** | `app/src/androidTest/` | `./gradlew :app:connectedDebugAndroidTest` | yes | +| **Unit** | `app/src/test/` | `./gradlew :app:testDebugUnitTest` | no | + +The instrumented tests are Espresso/UiAutomator against the View hierarchy. +**Each one encodes a real incident** — video double-start crashes, SAF grant `SecurityException`s, +extension bind `UnsupportedOperationException`s, gallery NPEs. + +- **Never delete a regression test without its replacement in the same commit.** +- Run a single class with: + ```sh + ./gradlew :app:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=app.grapheneos.camera.VideoCapturerRegressionTest + ``` +- **Known flake:** + `VideoCapturerRegressionTest.leavingACaptureSessionWhileRecording_defersThePreview` + fails only in full-suite runs, and does so on unmodified `main` too. Re-run it alone before + attributing the failure to your diff. + +--- + +## Architecture + +### Legacy + +The pre-migration code has no DI, no ViewModels, no coroutines in the camera path (raw +`thread {}`, `Executors`, `Handler`). +`CamConfig` holds `private val mActivity: MainActivity` and some of its properties read the View +tree directly (e.g. `requireLocation`'s getter returns +`mActivity.settingsDialog.locToggle.isChecked`). This coupling is the thing the migration exists to +undo — do not add to it. + +### Target + +Compose + Hilt + per-screen unidirectional data flow + `data`/`domain`/`ui` layering; Material3 +Expressive styling. +Strategy is foundation-first: extract a testable domain layer underneath the existing Views +(keeping the instrumented regression suite green *and unmodified*), then replace the UI one screen +at a time — **leaf screens first, viewfinder last**. + +### Architectural rules (new code) + +Every migrated feature follows the same shape — when in doubt, open an already-migrated feature in +this repo and copy it. + +**Layering.** Dependency direction is `ui → domain → data`; `data` and `domain` never import `ui`, +and nothing below `ui` touches Compose or an Activity. + +**Features are siblings, not dependencies.** A feature package imports its own layers plus shared +`core`/`common` code — never another feature. What two features both need moves down into a shared +package rather than being reached across for. This is what keeps a later module split a directory +move instead of an untangling. + +**Everything injectable is an interface + `Impl` pair.** Callers depend on `interface +PhotosRepository`; the implementation is `internal class PhotosRepositoryImpl` bound to it in a DI +module. Both live in the **same file, named after the interface** (`PhotosRepository.kt`). This +holds for repositories, use cases, mappers, effect handlers — anything that gets injected — so +every dependency can be faked in tests and previews. The one naming exception is ViewModels: the +interface is `ScreenModel` and the implementation is `ViewModel` (no `Impl`), both in +`ViewModel.kt` — see the screen contract below. + +Roles: + +- **Repository** (`data//repository/`): the feature's public data API. Exposes `Flow`s + and `suspend` functions; applies `flowOn(dispatcher)` itself so callers never think about + threads. +- **Use case** (`domain//usecase/`): one verb per class, named as the verb + (`ShareCapturedItem`), interface exposing `suspend operator fun invoke(...)`. Returns a + sealed result type from `domain//model/`, not exceptions. +- **Mapper**: pure `map(input): output` — no side effects, no Context. +- Dispatchers are injected via qualifiers (`@IoDispatcher`, `@DefaultDispatcher`) declared in + `di/core/`, never referenced as `Dispatchers.IO` inline. +- **DI** (`di//`): one `@Module @InstallIn(SingletonComponent::class)` abstract class per + feature with `@Binds @Reusable` for each interface→Impl pair. Everything is `internal`. + +**Unidirectional data flow per screen** (`ui//screen/`): + +- The ViewModel implements a `ScreenModel` interface exposing exactly + `uiState: StateFlow`, `effects: Flow`, `onAction(Action)`. The screen + composable takes the **interface** (defaulted to `viewModel<...>()`), so previews and tests + substitute a fake without Hilt. The screen collects `uiState` with + `collectAsStateWithLifecycle()` — never plain `collectAsState()`. +- `UiState` (`screen/model/`): `@Immutable` data class, every field defaulted so `State()` is the + loading state; lists are `kotlinx.collections.immutable.ImmutableList`. Nested per-item types are + `UiModel`s in the same package, built by a `screen/mapper/` UiStateMapper. +- `Action`: sealed interface of user events, named past-tense from the UI's point of view + (`ShutterClicked`, `LensSwitchClicked`) — never imperative commands. The ViewModel's `onAction` + is a single exhaustive `when`. +- `ScreenEffect`: sealed interface of one-shot events, emitted through + `Channel(capacity = Channel.BUFFERED)` exposed as `receiveAsFlow()` — never a StateFlow, which + would replay. Navigation is its own `NavEvent` sealed type (or an `onNavigateBack`-style lambda + for simple back). +- **EffectHandler** (`screen/`): interface + `Impl` constructed with the Activity — the *only* + place intents, toasts, clipboard, and `finish()` live. The screen collects + `screenModel.effects` in a `LaunchedEffect(screenModel)` and forwards to the handler via + `rememberUpdatedState`. For Camera this is where the security-sensitive behavior concentrates: + the handler holds the real Activity, so prefs stay entry-mode-scoped and intent launches stay + behind `QrTile`'s keyguard interceptor by construction. +- Screen file shape: public `Screen` wires the model and effects; a private, stateless + `Content(uiState, onAction, ...)` renders it; `@PreviewLightDark` previews call `Content` + with literal state. In-file aliases keep signatures readable: + `import ...model.ViewfinderAction as Action`. +- A ViewModel that outgrows one file splits into `delegate/` classes by responsibility + (selection, optimistic updates, ...), not into a bigger ViewModel. + +--- + +## Coding Conventions + +These govern **new and rewritten code**. Existing files predate them; do not reformat a file you are +not otherwise changing — whitespace churn buries the diff and makes the migration unreviewable. + +### Kotlin + +- **No expression-body functions.** Always a block body with an explicit return type: + ```kotlin + // WRONG + fun currentMode() = camConfig.currentMode + + // CORRECT + fun currentMode(): CameraMode { + return camConfig.currentMode + } + ``` + Return type is omitted for functions returning `Unit`; write `fun bind() {`, not + `fun bind(): Unit {`. +- **No fully-qualified names in code.** Import the type and use the short name. Qualify only to + resolve an import conflict. +- **Named arguments** for Kotlin calls — constructors, factories, builders. Exceptions: unambiguous + single-argument calls (`listOf(item)`, `launch(defaultDispatcher)`), stdlib higher-order functions + (`map { }`, `filter { }`), and Java interop. +- **Descriptive names, no abbreviations.** `context` not `ctx`, `manager` not `mgr`. Short names are + fine only when universally unambiguous: `id`, `uri`, `i`/`j` in tight loops, `{ it }`. +- **Parameter formatting:** one line if it fits; otherwise one parameter per line with a trailing + comma. Same for call sites. +- **Trailing commas** in every multi-line parameter list, argument list, `when` branch list and + collection literal. Never on a single line. +- **Never break the line after `=`.** The right-hand side starts on the same line as the assignment; + wrap inside it. Breaking after `=` costs a line and an indent level and separates the name from + the thing that produces it — ktlint's `multiline-expression-wrapping` would impose it, which is + one reason this project's `.editorconfig` selects `android_studio` over `ktlint_official`. + + ```kotlin + // WRONG + val info = + packageManager.getActivityInfo( + ComponentName(context.packageName, "$PACKAGE.ui.activities.QrTile"), + PackageManager.MATCH_ALL, + ) + + // CORRECT + val info = packageManager.getActivityInfo( + ComponentName(context.packageName, "$PACKAGE.ui.activities.QrTile"), + PackageManager.MATCH_ALL, + ) + + // CORRECT — when the call itself does not fit, break the chain instead + val info = packageManager + .getActivityInfo( + ComponentName(context.packageName, "$PACKAGE.ui.activities.QrTile"), + PackageManager.MATCH_ALL, + ) + ``` + + Break after `=` only when nothing else fits — a `when`/`if` expression body, or a single call whose + own name already overruns the line. +- **Never `!!` outside tests.** Prefer `?.`, `?:`, and `requireNotNull(x) { "why" }`. +- **Explicit dispatcher on every `scope.launch(...)`.** Never rely on the scope's implicit + dispatcher. Pass it positionally, not as `context = ...`. +- **`internal` by default** for anything not needed outside the module; `private` aggressively for + implementation details. +- **No wildcard imports.** +- **Top-level declarations are for genuinely shared, standalone things.** A constant, function, or + extension function that relates to a specific class/interface — or is `private` to its file — + belongs inside that class (or its `companion object`), not at top level. Reserve the top level + for declarations with no owning type. +- **Constants** are `private const val` in `UPPER_SNAKE_CASE` — in a `companion object` placed last + in the class body when they relate to a class, at file top level only otherwise. +- **Prefer top-level functions over `object`.** Use `object` only for a genuine stateful singleton + or + to implement an interface. +- **Prefer `when` over `if` for value-producing expressions** — `val x = when {`, not + `val x = if (`. +- **Functions stay focused and compact**, with **no more than 2 `return`s**. +- **Shared helpers take an explicit `activity`/`context` parameter — do not write them as `Activity` + extensions.** `CapturedItems.kt`'s `shareCapturedItem(activity, item)` is the pattern to follow. + An extension hides which Activity a call is scoped to; the secure-session prefs isolation and + `QrTile`'s keyguard interceptor both depend on that being visible at the call site. +- **Best-practice verification:** if you are not certain about a framework or API behavior, check + current official documentation before changing it. CameraX in particular has moved a great deal. + +### Comments + +**The default is no comment.** Code that needs prose to be understood is code that needs rewriting: +a clearer name, a smaller function, or a named intermediate `val` solves more comprehension problems +than any sentence placed above the line. Reach for one of those first, every time. + +A comment earns its place only by carrying what the code cannot — **why**, never **what**. Before +writing one, say what a reader loses if it is deleted. If that answer is a paraphrase of the code, +it is not an answer; delete the comment. + +Worth writing: + +- A constraint from outside the file — a platform or OEM bug, an API that documents one thing and + does another, an ordering the framework requires. These are invisible in the code and expensive to + rediscover. +- Why the obvious approach was rejected, where a reader would otherwise "fix" it back. +- KDoc on a public interface whose contract its signature does not convey: what a caller may assume, + what it must not. +- A `TODO`/`FIXME` naming the condition that resolves it. + +Not worth writing: + +- Restating the next line, the signature, or the type. +- Section banners, decorative rules, `// endregion` scaffolding. +- Narrating the edit rather than the code — "now handles X", "moved from Y", "new". The diff and the + commit message carry history; a comment describes the code as it stands. +- Explaining language or framework basics, or restating a rule from this document. + +Two consequences worth stating outright. Comment density is not a quality signal and a comment is +not a way to show work — a file whose every comment is a *why* reads faster than one where each +comment must be checked against the code to find the two that matter. And a comment that has drifted +out of true is worse than no comment: when you change a line, the comments above it are part of that +change. + +### Testability + +Design new code so its behavior is unit-testable without a device — that is the whole +payoff of the migration: + +- Extract interfaces for data sources and repositories so they can be faked. +- Anything holding business logic must be constructible without an Android `Context`; inject + dependencies through the constructor. +- Prefer pure functions for mappers and state transitions. +- Camera bind ordering is order-sensitive. Settings that trigger a rebind stay **synchronous + write-through** — StateFlow-collector-driven rebinds conflate and reorder emissions. + +### Compose + +- **Material 3 only.** Colors, typography and shapes all come from `CameraTheme` / + `MaterialTheme` — never a hardcoded color, and corners come from `MaterialTheme.shapes`, not an + inline `RoundedCornerShape`. +- **Dynamic color first.** The theme uses the user's device colors (`dynamicDarkColorScheme` / + `dynamicLightColorScheme`). Introduce a custom color only when a real need can't be met by an + existing `MaterialTheme.colorScheme` role, and add it as a theme extension — not inline in a + composable. +- **State hoisting:** composables below screen level are stateless, receiving state as parameters + and + emitting events via lambdas. No ViewModel access below screen level. +- **`modifier: Modifier = Modifier`** as the first optional parameter; chain modifiers, never + reassign. Pass it to the outermost layout the composable emits, exactly once — a composable that + drops its `modifier` or applies it to an inner child breaks its callers' layout expectations. +- **`LaunchedEffect` keys** are stable inputs only — wrap changing callbacks in + `rememberUpdatedState` rather than keying on them. +- One primary public composable per file, `PascalCase`, file named after it. `@Preview` functions + stay in the file that declares the composable they preview. + +### Resources + +User-visible strings go in `res/values/strings.xml` — never hardcoded in Kotlin. Dimensions shared +with XML layouts live in `dimens.xml`; in Compose use `dp`/`sp` directly. + +**Deleting a layout means deleting its resources.** When an XML layout goes away, sweep `values/` +for the strings, dimens, styles and colors only it referenced and remove them in the same commit — +migrating the UI is exactly when they stop being reachable, and left behind they read as live. + +--- + +## Dependencies + +`gradle/libs.versions.toml` is the single source of truth. **Never put a raw version string in a +`build.gradle.kts`.** + +- **Do not add a dependency before something uses it.** Every task here is an upstream PR, and "adds + a dependency nothing references" is the shape of PR a maintainer rejects — correctly. Each + dependency lands in the change whose code first needs it. +- Keep version, library and plugin lists **sorted case-insensitively**; blank-line groups (runtime, + test, tooling) are fine, each sorted internally. +- **CameraX is strictly pinned.** The app imports three CameraX `internal` APIs that carry no + compatibility guarantee, so a bump can break capture *at runtime* while CI stays green. The + catalog uses `strictly` so that a bump fails resolution instead. Read the comment on `camerax` in + `gradle/libs.versions.toml` before touching it; replacing the three imports with supported + equivalents is its own change, and comes first. +- **Dependency hash verification is enforced** via `gradle/verification-metadata.xml` — every + artifact's checksum is pinned, so any new or changed dependency fails the build until its hashes + are recorded there. On a verification error: **stop and ask the user to fix it.** Do not edit + `verification-metadata.xml`, regenerate it, or pass `--write-verification-metadata` yourself — + the whole point of the file is that a human vouches for each hash. + +--- + +## File Naming + +| Type | Convention | Example | +|---------------|----------------------------------------------------|---------------------------------| +| Kotlin source | PascalCase | `VideoCapturer.kt` | +| Injectable | Named after the interface; `Impl` in the same file | `PhotosRepository.kt` | +| Composable | PascalCase, matches composable | `CaptureButton.kt` | +| UI state | PascalCase + `UiState` | `ViewfinderUiState.kt` | +| Extensions | PascalCase + `Extensions` | `SharedPrefsExtensions.kt` | +| Test | Subject + `RegressionTest`/`Test` | `PhotoQualityRegressionTest.kt` | +| Resources | snake_case | `settings_dialog.xml` | + +--- + +## Misc + +- **Do not commit unless the user explicitly asks.** Never `git push` unasked. +- **Never add a commit co-author unless the user explicitly asks.** +- Commit messages: imperative mood, describing the behavior change rather than the mechanism — + match the existing log ("Don't initialize the camera while its permission is not granted"). +- Test-facing seams in `CamConfig` (`mPlayer`, `photoQuality`, `camera`, `switchMode`, + `SettingValues`) are written to by the instrumented suite. They stay writable until the screen + that owns them is migrated. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 42e1eb0d8b01f1f4101c65f8258e74f679b483fc Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 5 Aug 2026 21:23:12 +0300 Subject: [PATCH 02/14] Add .editorconfig --- .editorconfig | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..4d6ec2ba --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +indent_size = 4 +indent_style = space +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_packages_to_use_import_on_demand = unset +ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1 +ij_kotlin_line_break_after_multiline_when_entry = false +ktlint_code_style = android_studio +ktlint_function_naming_ignore_when_annotated_with = Composable +ktlint_standard_filename = disabled +ktlint_standard_function-expression-body = disabled +ktlint_standard_function-signature = disabled +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_blank-line-between-when-conditions = disabled +max_line_length = 100 From e3467db86e661231ee28e1a9132e36a3e6139b11 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 5 Aug 2026 21:30:51 +0300 Subject: [PATCH 03/14] Pin CameraX to the version its internal APIs were verified against --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bd3ba351..90530343 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ kotlin = "2.4.10" ksp = "2.3.10" appcompat = "1.7.1" -camerax = "1.6.1" +camerax = { strictly = "1.6.1" } constraintlayout = "2.2.2" coreKtx = "1.19.0" material = "1.14.0" From fa7fa52f7d49a06b8dca55720389ef4c6ffb259d Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 5 Aug 2026 21:37:29 +0300 Subject: [PATCH 04/14] Add a unit test source set and cover the ephemeral prefs used on the lockscreen --- app/build.gradle.kts | 12 + .../camera/util/EphemeralSharedPrefsTest.kt | 122 +++++ app/src/test/resources/robolectric.properties | 1 + gradle/libs.versions.toml | 4 + gradle/verification-metadata.xml | 427 ++++++++++++++++++ 5 files changed, 566 insertions(+) create mode 100644 app/src/test/java/app/grapheneos/camera/util/EphemeralSharedPrefsTest.kt create mode 100644 app/src/test/resources/robolectric.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4becedc2..ac0cc18d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,6 +89,14 @@ android { androidResources { localeFilters += listOf("en") } + + testOptions { + unitTests { + // Robolectric builds its application under test from the merged manifest and + // resources; without this it cannot start one. + isIncludeAndroidResources = true + } + } } dependencies { @@ -101,6 +109,10 @@ dependencies { implementation(libs.zxing.core) + testImplementation(libs.junit4) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core.ktx) + androidTestImplementation(libs.androidx.test.core.ktx) androidTestImplementation(libs.androidx.test.ext.junit.ktx) androidTestImplementation(libs.androidx.test.rules) diff --git a/app/src/test/java/app/grapheneos/camera/util/EphemeralSharedPrefsTest.kt b/app/src/test/java/app/grapheneos/camera/util/EphemeralSharedPrefsTest.kt new file mode 100644 index 00000000..a3c4281f --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/util/EphemeralSharedPrefsTest.kt @@ -0,0 +1,122 @@ +package app.grapheneos.camera.util + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * [EphemeralSharedPrefs] is what stops a lockscreen session from changing the settings the + * owner sees after unlocking: SecureMainActivity and SecureCaptureActivity override + * getSharedPreferences() to hand out one of these, cloned from the real preferences but + * backed by memory, and CamConfig deliberately reads its preferences through the activity so + * it inherits that. + * + * The clone being one-way is the entire security property, and nothing asserted it. + */ +@RunWith(RobolectricTestRunner::class) +class EphemeralSharedPrefsTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + private fun persistentPrefs(): SharedPreferences { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + private fun ephemeralPrefs(cloneOriginal: Boolean = true): SharedPreferences { + return EphemeralSharedPrefsNamespace() + .getPrefs(context, PREFS_NAME, Context.MODE_PRIVATE, cloneOriginal = cloneOriginal) + } + + @Before + fun resetPersistentPrefs() { + persistentPrefs().edit().clear().commit() + } + + @Test + fun clonesExistingValuesFromThePersistentPrefs() { + persistentPrefs().edit().putInt("photoQuality", 85).commit() + + assertEquals(85, ephemeralPrefs().getInt("photoQuality", -1)) + } + + @Test + fun writesNeverReachThePersistentPrefs() { + persistentPrefs().edit().putInt("photoQuality", 85).commit() + + val ephemeral = ephemeralPrefs() + ephemeral.edit().putInt("photoQuality", 20).commit() + + assertEquals(20, ephemeral.getInt("photoQuality", -1)) + assertEquals(85, persistentPrefs().getInt("photoQuality", -1)) + } + + @Test + fun removalsNeverReachThePersistentPrefs() { + persistentPrefs().edit().putBoolean("includeAudio", true).commit() + + val ephemeral = ephemeralPrefs() + ephemeral.edit().remove("includeAudio").commit() + + assertFalse(ephemeral.contains("includeAudio")) + assertTrue(persistentPrefs().contains("includeAudio")) + } + + @Test + fun clearNeverReachesThePersistentPrefs() { + persistentPrefs().edit().putBoolean("includeAudio", true).commit() + + val ephemeral = ephemeralPrefs() + ephemeral.edit().clear().commit() + + assertFalse(ephemeral.contains("includeAudio")) + assertTrue(persistentPrefs().contains("includeAudio")) + } + + @Test + fun aRepeatedLookupKeepsTheSessionsChanges() { + persistentPrefs().edit().putInt("photoQuality", 85).commit() + val namespace = EphemeralSharedPrefsNamespace() + + val first = namespace + .getPrefs(context, PREFS_NAME, Context.MODE_PRIVATE, cloneOriginal = true) + first.edit().putInt("photoQuality", 42).commit() + val second = namespace + .getPrefs(context, PREFS_NAME, Context.MODE_PRIVATE, cloneOriginal = true) + + // A second lookup that re-cloned from disk would silently discard everything the + // session changed and hand back the persistent value instead. + assertEquals(42, second.getInt("photoQuality", -1)) + } + + @Test + fun startsEmptyWhenNotCloning() { + persistentPrefs().edit().putInt("photoQuality", 85).commit() + + assertFalse(ephemeralPrefs(cloneOriginal = false).contains("photoQuality")) + } + + @Test + fun rejectsAnyModeOtherThanPrivate() { + val failure = runCatching { + EphemeralSharedPrefsNamespace() + .getPrefs(context, PREFS_NAME, Context.MODE_APPEND, cloneOriginal = true) + }.exceptionOrNull() + + assertTrue( + "Only MODE_PRIVATE is supported, and anything else must fail loudly rather than" + + " return preferences with the wrong semantics, but got $failure", + failure is IllegalArgumentException, + ) + } + + private companion object { + // CamConfig.COMMON_SHARED_PREFS_NAME + const val PREFS_NAME = "commons" + } +} diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 00000000..3f67ea5a --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=35 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 90530343..edcc3cb6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,8 @@ androidxTestCore = "1.7.0" androidxTestExtJunit = "1.3.0" androidxTestRules = "1.7.0" androidxTestRunner = "1.7.0" +junit4 = "4.13.2" +robolectric = "4.16.1" [libraries] androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -33,6 +35,8 @@ androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "and androidx-test-ext-junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "androidxTestExtJunit" } androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTestRules" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } +junit4 = { module = "junit:junit", version.ref = "junit4" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } ksp-gradle-plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 3bc86b39..53642c9c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1735,6 +1735,17 @@ + + + + + + + + + + + @@ -1926,6 +1937,20 @@ + + + + + + + + + + + + + + @@ -2877,6 +2902,20 @@ + + + + + + + + + + + + + + @@ -2905,6 +2944,11 @@ + + + + + @@ -3187,6 +3231,20 @@ + + + + + + + + + + + + + + @@ -3359,6 +3417,11 @@ + + + + + @@ -3401,6 +3464,20 @@ + + + + + + + + + + + + + + @@ -3510,6 +3587,23 @@ + + + + + + + + + + + + + + + + + @@ -3550,6 +3644,11 @@ + + + + + @@ -3923,6 +4022,25 @@ + + + + + + + + + + + + + + + + + + + @@ -3956,6 +4074,20 @@ + + + + + + + + + + + + + + @@ -5289,6 +5421,20 @@ + + + + + + + + + + + + + + @@ -5410,6 +5556,20 @@ + + + + + + + + + + + + + + @@ -7497,6 +7657,20 @@ + + + + + + + + + + + + + + @@ -7536,6 +7710,20 @@ + + + + + + + + + + + + + + @@ -7550,6 +7738,20 @@ + + + + + + + + + + + + + + @@ -7589,6 +7791,212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7608,6 +8016,11 @@ + + + + + @@ -7632,5 +8045,19 @@ + + + + + + + + + + + + + + From f2577922c36b83ac0f9fe0c58e22a910359e30bd Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 5 Aug 2026 21:37:49 +0300 Subject: [PATCH 05/14] Cover the camera intent contracts and lockscreen preference isolation --- .../camera/EntryPointContractTest.kt | 192 ++++++++++++++++++ .../camera/SecurePrefsIsolationTest.kt | 131 ++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 app/src/androidTest/java/app/grapheneos/camera/EntryPointContractTest.kt create mode 100644 app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt diff --git a/app/src/androidTest/java/app/grapheneos/camera/EntryPointContractTest.kt b/app/src/androidTest/java/app/grapheneos/camera/EntryPointContractTest.kt new file mode 100644 index 00000000..0d276c3e --- /dev/null +++ b/app/src/androidTest/java/app/grapheneos/camera/EntryPointContractTest.kt @@ -0,0 +1,192 @@ +package app.grapheneos.camera + +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import app.grapheneos.camera.ui.activities.SecureActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EntryPointContractTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val packageManager: PackageManager = context.packageManager + + private fun activityInfoFor(action: String): ActivityInfo { + val intent = Intent(action).setPackage(context.packageName) + val matches = packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL) + + assertEquals( + "Exactly one component in this app must answer $action, but got" + + " ${matches.map { it.activityInfo.name }}", + 1, + matches.size, + ) + return matches.single().activityInfo + } + + private fun isSecureActivity(info: ActivityInfo): Boolean { + return SecureActivity::class.java.isAssignableFrom(Class.forName(info.name)) + } + + private fun assertHandledBy( + action: String, + expectedComponent: String, + ) { + assertEquals( + "$action must be handled by $expectedComponent", + "$PACKAGE.$expectedComponent", + activityInfoFor(action).name, + ) + } + + private fun assertIsLockscreenEntryPoint( + action: String, + expectedAffinity: String, + ) { + val info = activityInfoFor(action) + + assertTrue( + "${info.name} must show over the keyguard, or $action does nothing on a locked" + + " phone", + info.flags and FLAG_SHOW_WHEN_LOCKED != 0, + ) + assertTrue( + "${info.name} must be excluded from recents, or what a locked session captured is" + + " listed to whoever picks the phone up next", + info.flags and ActivityInfo.FLAG_EXCLUDE_FROM_RECENTS != 0, + ) + + assertTrue( + "${info.name} must keep its own taskAffinity ending in" + + " .ui.activities.$expectedAffinity, or the locked session can surface the" + + " unlocked task, but it is ${info.taskAffinity}", + info.taskAffinity.orEmpty().endsWith(".ui.activities.$expectedAffinity"), + ) + + assertTrue( + "${info.name} must implement SecureActivity: it is what the preferences binding" + + " branches on, and without it a lockscreen session writes the owner's store", + isSecureActivity(info), + ) + } + + @Test + fun stillImageCameraIsAnAliasOntoTheMainActivity() { + val info = activityInfoFor("android.media.action.STILL_IMAGE_CAMERA") + + assertEquals("$PACKAGE.ui.activities.CameraLauncher", info.name) + assertEquals("$PACKAGE.ui.activities.MainActivity", info.targetActivity) + } + + @Test + fun videoCameraLaunchesTheVideoOnlyActivity() { + assertHandledBy("android.media.action.VIDEO_CAMERA", "ui.activities.VideoOnlyActivity") + } + + @Test + fun imageCaptureLaunchesTheCaptureActivity() { + assertHandledBy("android.media.action.IMAGE_CAPTURE", "ui.activities.CaptureActivity") + } + + @Test + fun videoCaptureLaunchesTheVideoCaptureActivity() { + assertHandledBy( + "android.media.action.VIDEO_CAPTURE", + "ui.activities.VideoCaptureActivity", + ) + } + + @Test + fun secureStillImageCameraLaunchesTheSecureMainActivity() { + assertHandledBy( + "android.media.action.STILL_IMAGE_CAMERA_SECURE", + "ui.activities.SecureMainActivity", + ) + } + + @Test + fun secureImageCaptureLaunchesTheSecureCaptureActivity() { + assertHandledBy( + "android.media.action.IMAGE_CAPTURE_SECURE", + "ui.activities.SecureCaptureActivity", + ) + } + + @Test + fun secureStillImageCameraIsALockscreenEntryPoint() { + assertIsLockscreenEntryPoint( + action = "android.media.action.STILL_IMAGE_CAMERA_SECURE", + expectedAffinity = "SecureMainActivity", + ) + } + + @Test + fun secureImageCaptureIsALockscreenEntryPoint() { + assertIsLockscreenEntryPoint( + action = "android.media.action.IMAGE_CAPTURE_SECURE", + expectedAffinity = "SecureCaptureActivity", + ) + } + + @Test + fun theUnlockedEntryPointsDoNotShowOverTheKeyguard() { + // The mirror of the assertions above: were every activity showWhenLocked, they would + // pass while the distinction they exist to protect had been erased. + listOf( + "android.media.action.VIDEO_CAMERA", + "android.media.action.IMAGE_CAPTURE", + "android.media.action.VIDEO_CAPTURE", + ).forEach { action -> + val info = activityInfoFor(action) + + assertEquals( + "${info.name} answers the non-secure $action and must not show over the" + + " keyguard", + 0, + info.flags and FLAG_SHOW_WHEN_LOCKED, + ) + assertFalse( + "${info.name} answers the non-secure $action and must not be a SecureActivity," + + " or it is handed a throwaway copy of the preferences it is meant to keep", + isSecureActivity(info), + ) + } + } + + @Test + fun qrTileKeepsTheNameAndFlagsSystemUiDependsOn() { + val info = packageManager.getActivityInfo( + ComponentName(context.packageName, "$PACKAGE.ui.activities.QrTile"), + PackageManager.MATCH_ALL, + ) + + assertTrue( + "QrTile must stay exported — SystemUI starts it from outside the app", + info.exported, + ) + assertTrue( + "QrTile must show over the keyguard; it is a lockscreen shortcut target", + info.flags and FLAG_SHOW_WHEN_LOCKED != 0, + ) + assertTrue( + "QrTile must be excluded from recents", + info.flags and ActivityInfo.FLAG_EXCLUDE_FROM_RECENTS != 0, + ) + assertNull("QrTile is a real activity, not an alias", info.targetActivity) + } + + private companion object { + const val PACKAGE = "app.grapheneos.camera" + + // ActivityInfo.FLAG_SHOW_WHEN_LOCKED is @hide + const val FLAG_SHOW_WHEN_LOCKED = 0x800000 + } +} diff --git a/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt new file mode 100644 index 00000000..0529946e --- /dev/null +++ b/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt @@ -0,0 +1,131 @@ +package app.grapheneos.camera + +import android.Manifest +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import app.grapheneos.camera.ui.activities.MainActivity +import app.grapheneos.camera.ui.activities.SecureMainActivity +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * A lockscreen session may read the owner's settings but must never write them: whoever picks + * up a locked phone would otherwise be able to change what the owner sees after unlocking. + * SecureMainActivity enforces this by overriding getSharedPreferences() to return an ephemeral + * clone, and CamConfig obtains its preferences through the activity — rather than through the + * application context — precisely so it inherits that. + * + * A settings repository injected with the application context would satisfy every other test + * in this suite and silently undo it. + */ +@RunWith(AndroidJUnit4::class) +class SecurePrefsIsolationTest { + @get:Rule + val grantPermissions: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.CAMERA, + ) + + /** Both activities bind a camera, which a dozing or locked device cannot provide. */ + @get:Rule + val screenAwake = ScreenAwakeRule() + + private val context: Context = InstrumentationRegistry + .getInstrumentation() + .targetContext + .applicationContext + + private fun persistentPrefs(): SharedPreferences { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + @After + fun removeProbeKey() { + persistentPrefs().edit().remove(PROBE_KEY).commit() + } + + @Test + fun theSecureActivityDoesNotHandOutThePersistentPrefs() { + ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + assertNotSame( + "SecureMainActivity handed out the persistent preferences — a locked" + + " session can now overwrite the owner's settings", + persistentPrefs(), + activity.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), + ) + } + } + } + + @Test + fun writesInASecureSessionDoNotChangeThePersistentPrefs() { + persistentPrefs().edit().putInt(PROBE_KEY, 1).commit() + + ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putInt(PROBE_KEY, 2) + .commit() + } + } + + assertEquals( + "A secure session wrote through to the persistent preferences", + 1, + persistentPrefs().getInt(PROBE_KEY, -1), + ) + } + + @Test + fun aSecureSessionStillReadsTheOwnersSettings() { + persistentPrefs().edit().putInt(PROBE_KEY, 3).commit() + + ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + assertEquals( + "The isolation must be one-way: a lockscreen session still honours the" + + " settings the owner chose", + 3, + activity + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getInt(PROBE_KEY, -1), + ) + } + } + } + + @Test + fun theRegularActivityDoesWriteThePersistentPrefs() { + // The mirror of the tests above: if this ever fails, they would pass for the wrong + // reason — because nothing writes preferences at all. + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putInt(PROBE_KEY, 7) + .commit() + } + } + + assertEquals(7, persistentPrefs().getInt(PROBE_KEY, -1)) + } + + private companion object { + // CamConfig.COMMON_SHARED_PREFS_NAME + const val PREFS_NAME = "commons" + + /** Not a real setting, so a failed run cannot corrupt the app's configuration. */ + const val PROBE_KEY = "securePrefsIsolationProbe" + } +} From 4c2d43a18e3dda11dca29ee2b88778e40b7f56fd Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 5 Aug 2026 21:41:45 +0300 Subject: [PATCH 06/14] Add detekt and ktlint, baselining the existing sources --- app/build.gradle.kts | 59 + app/config/ktlint/baseline.xml | 932 +++++++++++++++ app/detekt-baseline-debug.xml | 235 ++++ app/detekt-baseline-debugAndroidTest.xml | 11 + build.gradle.kts | 39 +- config/detekt/detekt.yml | 52 + gradle/libs.versions.toml | 6 + gradle/verification-metadata.xml | 1337 ++++++++++++++++++++++ settings.gradle.kts | 3 + 9 files changed, 2673 insertions(+), 1 deletion(-) create mode 100644 app/config/ktlint/baseline.xml create mode 100644 app/detekt-baseline-debug.xml create mode 100644 app/detekt-baseline-debugAndroidTest.xml create mode 100644 config/detekt/detekt.yml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ac0cc18d..9bd88bf6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,8 @@ +import dev.detekt.gradle.Detekt +import dev.detekt.gradle.DetektCreateBaselineTask import java.io.FileInputStream import java.util.Properties +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile val keystorePropertiesFile = rootProject.file("keystore.properties") val useKeystoreProperties = keystorePropertiesFile.canRead() @@ -10,6 +13,62 @@ if (useKeystoreProperties) { plugins { alias(libs.plugins.android.application) + alias(libs.plugins.detekt) +} + +detekt { + basePath.set(rootDir) + baseline = file("detekt-baseline.xml") + buildUponDefaultConfig = true + config.setFrom(rootProject.file("config/detekt/detekt.yml")) + ignoredBuildTypes = listOf("release") + parallel = true +} + +// detekt's classpath convention is the compilation's dependencies and nothing else, so BuildConfig +// and androidxc/ resolve to nothing and every type-aware rule goes quiet instead of reporting. A +// Gradle convention cannot be appended to: `from` would discard it, hence `setFrom` with both. +fun addOwnClassesToDetektClasspath( + classpath: ConfigurableFileCollection, + variantName: String, +) { + classpath.setFrom( + tasks.named("compile${variantName}Kotlin").map { it.libraries }, + tasks.named("compile${variantName}JavaWithJavac").map { it.outputs.files }, + ) +} + +// Only the variants `check` gates on below. The plugin's other detekt tasks analyse a source set +// at a time without types and have no compilation to take a classpath from. +listOf("Debug", "DebugUnitTest", "DebugAndroidTest").forEach { variantName -> + tasks + .withType() + .matching { it.name == "detekt$variantName" } + .configureEach { + addOwnClassesToDetektClasspath(classpath, variantName) + } + + tasks + .withType() + .matching { it.name == "detektBaseline$variantName" } + .configureEach { + addOwnClassesToDetektClasspath(classpath, variantName) + } +} + +// The aggregate `detekt` task analyses every source set at once without type resolution, so it +// cannot see what the type-aware rules exist for. The debug variants cover the same sources with +// types, so `check` gates on those and the aggregate stays off. +tasks.named("check") { + dependsOn( + tasks.named("detektDebug"), + tasks.named("detektDebugUnitTest"), + tasks.named("detektDebugAndroidTest"), + ) +} + +tasks.named("detekt") { + enabled = false } java { diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml new file mode 100644 index 00000000..0d58475d --- /dev/null +++ b/app/config/ktlint/baseline.xml @@ -0,0 +1,932 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml new file mode 100644 index 00000000..266f2311 --- /dev/null +++ b/app/detekt-baseline-debug.xml @@ -0,0 +1,235 @@ + + + + + ComplexCondition:InAppGallery.kt:InAppGallery$width != null && height != null && width > 0 && height > 0 + ComplexCondition:ZoomableImageView.kt:ZoomableImageView$oldMeasuredHeight == viewWidth && oldMeasuredHeight == viewHeight || viewWidth == 0 || viewHeight == 0 + CyclomaticComplexMethod:CamConfig.kt:CamConfig$@SuppressLint("RestrictedApi") fun startCamera + CyclomaticComplexMethod:CamConfig.kt:CamConfig$fun loadSettings + CyclomaticComplexMethod:CapturedItems.kt:CapturedItems$private fun migratePreviousUris + CyclomaticComplexMethod:InAppGallery.kt:InAppGallery$override fun onCreate + CyclomaticComplexMethod:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails + CyclomaticComplexMethod:MainActivity.kt:MainActivity$@SuppressLint("ClickableViewAccessibility") override fun onCreate + CyclomaticComplexMethod:MainActivity.kt:MainActivity$fun onDeviceAngleChange + CyclomaticComplexMethod:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$override fun onSensorChanged + CyclomaticComplexMethod:VideoCapturer.kt:VideoCapturer$fun startRecording + EmptyCatchBlock:QRAnalyzer.kt:QRAnalyzer${ } + EmptyFunctionBlock:ActivityLifeCycleHelper.kt:ActivityLifeCycleHelper${} + EmptyFunctionBlock:App.kt:App.<no name provided>${} + EmptyFunctionBlock:CamConfig.kt:CamConfig.<no name provided>${} + EmptyFunctionBlock:ImageCapturer.kt:ImageCapturer.<no name provided>${} + EmptyFunctionBlock:MainActivity.kt:MainActivity${} + EmptyFunctionBlock:MainActivity.kt:MainActivity.<no name provided>${} + EmptyFunctionBlock:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener${} + EmptyFunctionBlock:SettingsDialog.kt:SettingsDialog.<no name provided>${} + EmptyFunctionBlock:ZoomableImageView.kt:ZoomableImageView.<no name provided>${} + HasPlatformType:ImageSaver.kt:ImageSaver$val contentResolver = appContext.contentResolver + HasPlatformType:ImageSaver.kt:ImageSaver$val mainThreadExecutor = appContext.mainExecutor + HasPlatformType:ImageSaver.kt:ImageSaver.Companion$val imageCaptureCallbackExecutor = Executors.newSingleThreadExecutor() + HasPlatformType:InAppGallery.kt:InAppGallery$val asyncImageLoader = Executors.newSingleThreadExecutor() + HasPlatformType:InAppGallery.kt:InAppGallery$val asyncLoaderOfCapturedItems = Executors.newSingleThreadExecutor() + HasPlatformType:MainActivity.kt:MainActivity$val thumbnailLoaderExecutor = Executors.newSingleThreadExecutor() + HasPlatformType:SharedPrefs.kt:EphemeralSharedPrefs.Editor$val thread = Thread.currentThread() + ImplicitDefaultLocale:QRAnalyzer.kt:QRAnalyzer$"%.02f".format(fps) + ImplicitDefaultLocale:ZoomBar.kt:ZoomBar$String.format("%.1fx", zoomRatio) + InstanceOfCheckForException:CamConfig.kt:CamConfig$exception !is IllegalArgumentException + InstanceOfCheckForException:CamConfig.kt:CamConfig$exception !is UnsupportedOperationException + InstanceOfCheckForException:CamConfig.kt:CamConfig$exception is IllegalArgumentException + LargeClass:CamConfig.kt:CamConfig + LargeClass:MainActivity.kt:MainActivity : AppCompatActivityOnTouchListenerOnScaleGestureListenerOnGestureListenerOnDoubleTapListenerListener + LongMethod:BlurBitmap.kt:BlurBitmap$operator fun get: Bitmap + LongMethod:CamConfig.kt:CamConfig$@SuppressLint("RestrictedApi") fun startCamera + LongMethod:CamConfig.kt:CamConfig$fun loadSettings + LongMethod:CamConfig.kt:CamConfig$fun showMoreOptionsForQR + LongMethod:GallerySliderAdapter.kt:GallerySliderAdapter$override fun onBindViewHolder + LongMethod:InAppGallery.kt:InAppGallery$override fun onCreate + LongMethod:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails + LongMethod:MainActivity.kt:MainActivity$@SuppressLint("ClickableViewAccessibility") override fun onCreate + LongMethod:MainActivity.kt:MainActivity$fun onDeviceAngleChange + LongMethod:MainActivity.kt:MainActivity$fun onScanResultSuccess + LongMethod:MoreSettings.kt:MoreSettings$override fun onCreate + LongMethod:SettingsDialog.kt:SettingsDialog$fun selfIllumination + LongMethod:VideoCapturer.kt:VideoCapturer$fun startRecording + LongMethod:VideoPlayer.kt:VideoPlayer$override fun onCreate + MagicNumber:App.kt:App$2000 + MagicNumber:BlurBitmap.kt:BlurBitmap$0x0000ff + MagicNumber:BlurBitmap.kt:BlurBitmap$0x00ff00 + MagicNumber:BlurBitmap.kt:BlurBitmap$0xff0000 + MagicNumber:BlurBitmap.kt:BlurBitmap$16 + MagicNumber:BlurBitmap.kt:BlurBitmap$256 + MagicNumber:BlurBitmap.kt:BlurBitmap$8 + MagicNumber:CamConfig.kt:CamConfig$100 + MagicNumber:CamConfig.kt:CamConfig$95 + MagicNumber:CaptureActivity.kt:CaptureActivity$100 + MagicNumber:CaptureActivity.kt:CaptureActivity$1000000 + MagicNumber:CaptureActivity.kt:CaptureActivity$300 + MagicNumber:CountDownTimerUI.kt:CountDownTimerUI.<no name provided>$1000L + MagicNumber:CustomGrid.kt:CustomGrid$255 + MagicNumber:CustomGrid.kt:CustomGrid$3f + MagicNumber:CustomGrid.kt:CustomGrid$4f + MagicNumber:ExposureBar.kt:ExposureBar$300 + MagicNumber:ExposureBar.kt:ExposureBar$90f + MagicNumber:ImageCapturer.kt:ImageCapturer$200 + MagicNumber:InAppGallery.kt:InAppGallery$1000 + MagicNumber:InAppGallery.kt:InAppGallery$1000L + MagicNumber:InAppGallery.kt:InAppGallery$1000f + MagicNumber:InAppGallery.kt:InAppGallery$270 + MagicNumber:InAppGallery.kt:InAppGallery$300 + MagicNumber:InAppGallery.kt:InAppGallery$50 + MagicNumber:InAppGallery.kt:InAppGallery$500 + MagicNumber:InAppGallery.kt:InAppGallery$90 + MagicNumber:MainActivity.kt:MainActivity$0.05f + MagicNumber:MainActivity.kt:MainActivity$16 + MagicNumber:MainActivity.kt:MainActivity$180 + MagicNumber:MainActivity.kt:MainActivity$270 + MagicNumber:MainActivity.kt:MainActivity$270f + MagicNumber:MainActivity.kt:MainActivity$3 + MagicNumber:MainActivity.kt:MainActivity$300 + MagicNumber:MainActivity.kt:MainActivity$360f + MagicNumber:MainActivity.kt:MainActivity$4 + MagicNumber:MainActivity.kt:MainActivity$400 + MagicNumber:MainActivity.kt:MainActivity$5 + MagicNumber:MainActivity.kt:MainActivity$500 + MagicNumber:MainActivity.kt:MainActivity$7 + MagicNumber:MainActivity.kt:MainActivity$8 + MagicNumber:MainActivity.kt:MainActivity$800 + MagicNumber:MainActivity.kt:MainActivity$90 + MagicNumber:MainActivity.kt:MainActivity$90f + MagicNumber:PackageManagerUtils.kt:33 + MagicNumber:PreviewView.kt:16 + MagicNumber:PreviewView.kt:180 + MagicNumber:PreviewView.kt:3 + MagicNumber:PreviewView.kt:4 + MagicNumber:PreviewView.kt:9 + MagicNumber:QRAnalyzer.kt:QRAnalyzer$180 + MagicNumber:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$180 + MagicNumber:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$270 + MagicNumber:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$5 + MagicNumber:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$90 + MagicNumber:SettingsDialog.kt:SettingsDialog$150 + MagicNumber:SettingsDialog.kt:SettingsDialog$300 + MagicNumber:VideoCapturer.kt:VideoCapturer$1_000_000_000 + MagicNumber:VideoCapturer.kt:VideoCapturer$300 + MagicNumber:VideoPlayer.kt:VideoPlayer$300 + MagicNumber:ZoomBar.kt:ZoomBar$100 + MagicNumber:ZoomBar.kt:ZoomBar$100f + MagicNumber:ZoomBar.kt:ZoomBar$300 + MagicNumber:ZoomBar.kt:ZoomBar$90f + MatchingDeclarationName:ImageDecoderUtils.kt:ImageResizer : OnHeaderDecodedListener + MaxLineLength:CamConfig.kt:CamConfig$resolutionSelectorBuilder.setAllowedResolutionMode(ResolutionSelector.PREFER_HIGHER_RESOLUTION_OVER_CAPTURE_RATE) + MaxLineLength:CapturedItems.kt:CapturedItems$private + MaxLineLength:CapturedItems.kt:CapturedItems$val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME) + MaxLineLength:MainActivity.kt:MainActivity$if (cameraPermissionDialog != null && cameraPermissionDialog!!.isShowing) cameraPermissionDialog!!.cancel() + MaxLineLength:SettingsDialog.kt:SettingsDialog$mActivity.showMessage("Enabling audio while recording is not currently supported when it was disabled at the start") + MaxLineLength:SharedPrefs.kt:EphemeralSharedPrefs$override + MaxLineLength:SharedPrefs.kt:fun + NestedBlockDepth:CapturedItems.kt:CapturedItems$private fun migratePreviousUris + NestedBlockDepth:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails + NewLineAtEndOfFile:GSlideTransformer.kt:app.grapheneos.camera.GSlideTransformer.kt + NewLineAtEndOfFile:SettingsFrameLayout.kt:app.grapheneos.camera.ui.SettingsFrameLayout.kt + NewLineAtEndOfFile:SystemSettingsObserver.kt:app.grapheneos.camera.ktx.SystemSettingsObserver.kt + NewLineAtEndOfFile:VideoCaptureActivity.kt:app.grapheneos.camera.ui.activities.VideoCaptureActivity.kt + NewLineAtEndOfFile:VideoOnlyActivity.kt:app.grapheneos.camera.ui.activities.VideoOnlyActivity.kt + NoNameShadowing:CapturedItems.kt:CapturedItems${ Uri.parse(it) } + NoNameShadowing:CapturedItems.kt:CapturedItems${ dest.add(it) } + NoNameShadowing:MoreSettings.kt:MoreSettings${ if (it.toString().contains(CapturedItems.SAF_TREE_SEPARATOR)) { null } else { it } } + NoNameShadowing:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$xAngle + NoNameShadowing:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$zAngle + PrintStackTrace:InAppGallery.kt:InAppGallery$e + PrintStackTrace:MainActivity.kt:MainActivity$exception + PrintStackTrace:VideoCapturer.kt:VideoCapturer$e + PrintStackTrace:VideoCapturer.kt:e + ReturnCount:App.kt:App$fun getLocation: Location? + ReturnCount:App.kt:App$fun isAnyLocationProvideActive: Boolean + ReturnCount:BottomTabLayout.kt:BottomTabLayout$override fun onScrollChanged + ReturnCount:CamConfig.kt:CamConfig$@SuppressLint("RestrictedApi") fun startCamera + ReturnCount:CamConfig.kt:CamConfig$@androidx.annotation.OptIn(ExperimentalCamera2Interop::class) private fun canVerifyFeatureCombinations: Boolean + ReturnCount:CamConfig.kt:CamConfig$private fun isExtensionUsable: Boolean + ReturnCount:CamConfig.kt:CamConfig$private fun isLensFacingSupported : Boolean + ReturnCount:CamConfig.kt:CamConfig$private fun loadTabs + ReturnCount:CamConfig.kt:CamConfig$private fun videoQualityAsGroupableFeature: GroupableFeature? + ReturnCount:CapturedItems.kt:CapturedItems$fun parseCapturedItem: CapturedItem? + ReturnCount:ImageCapturer.kt:ImageCapturer$@SuppressLint("RestrictedApi") fun takePicture + ReturnCount:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails + ReturnCount:MainActivity.kt:MainActivity$override fun onTouch: Boolean + ReturnCount:MainActivity.kt:MainActivity$private fun onSwipeLeft + ReturnCount:MainActivity.kt:MainActivity$private fun onSwipeRight + ReturnCount:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$override fun onSensorChanged + ReturnCount:SettingsDialog.kt:SettingsDialog$private fun updatePanelRegion: Boolean + ReturnCount:Utils.kt:fun storageLocationToUiString: String + ReturnCount:VideoCapturer.kt:VideoCapturer$fun startRecording + ReturnCount:VideoCapturer.kt:VideoCapturer$private fun createRecordingContext: RecordingContext? + SwallowedException:CamConfig.kt:CamConfig$e : IllegalArgumentException + SwallowedException:CamConfig.kt:CamConfig$e: ExecutionException + SwallowedException:CaptureActivity.kt:CaptureActivity$e: Exception + SwallowedException:CapturedItems.kt:e: ActivityNotFoundException + SwallowedException:GallerySliderAdapter.kt:GallerySliderAdapter$e: Exception + SwallowedException:QRAnalyzer.kt:QRAnalyzer$e: ReaderException + SwallowedException:SettingsDialog.kt:SettingsDialog$exception: Exception + SwallowedException:SettingsDialog.kt:SettingsDialog.<no name provided>$exception: Exception + SwallowedException:VideoCapturer.kt:VideoCapturer$e: Exception + SwallowedException:VideoCapturer.kt:VideoCapturer$exception: Exception + ThrowsCount:ImageSaver.kt:ImageSaver$@Throws(ImageSaverException::class) private fun saveImageInner + TooGenericExceptionCaught:CamConfig.kt:CamConfig$e: Exception + TooGenericExceptionCaught:CamConfig.kt:CamConfig$exception: RuntimeException + TooGenericExceptionCaught:CaptureActivity.kt:CaptureActivity$e: Exception + TooGenericExceptionCaught:CapturedItems.kt:CapturedItems$e: Exception + TooGenericExceptionCaught:GallerySliderAdapter.kt:GallerySliderAdapter$e: Exception + TooGenericExceptionCaught:ImageSaver.kt:ImageSaver$deleteException: Exception + TooGenericExceptionCaught:ImageSaver.kt:ImageSaver$e: Exception + TooGenericExceptionCaught:InAppGallery.kt:InAppGallery$e: Exception + TooGenericExceptionCaught:MainActivity.kt:MainActivity$e: Exception + TooGenericExceptionCaught:MainActivity.kt:MainActivity$exception: Exception + TooGenericExceptionCaught:SettingsDialog.kt:SettingsDialog$exception: Exception + TooGenericExceptionCaught:SettingsDialog.kt:SettingsDialog.<no name provided>$exception: Exception + TooGenericExceptionCaught:VideoCapturer.kt:VideoCapturer$e: Exception + TooGenericExceptionCaught:VideoCapturer.kt:VideoCapturer$exception: Exception + TooGenericExceptionCaught:VideoCapturer.kt:e: Exception + TooGenericExceptionCaught:VideoPlayer.kt:VideoPlayer$e: Exception + TooManyFunctions:CapturedItems.kt:CapturedItems + TooManyFunctions:MainActivity.kt:MainActivity : AppCompatActivityOnTouchListenerOnScaleGestureListenerOnGestureListenerOnDoubleTapListenerListener + TopLevelPropertyNaming:ImageCapturer.kt:private const val imageFileFormat = ".jpg" + UnsafeCallOnNullableType:BlurBitmap.kt:BlurBitmap$sentBitmap.config!! + UnsafeCallOnNullableType:BottomTabLayout.kt:BottomTabLayout$getTabAt(it)!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$camera!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$cameraProvider!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$commonPref.getString( SettingValues.Key.STORAGE_LOCATION, SettingValues.Default.STORAGE_LOCATION )!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$imageCapture!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$modePref.getString(videoQualityKey, "")!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$videoCapture!! + UnsafeCallOnNullableType:CapturedItems.kt:CapturedItem.Companion.<no name provided>$source.readString()!! + UnsafeCallOnNullableType:CapturedItems.kt:CapturedItems$uri.authority!! + UnsafeCallOnNullableType:ImageCapturer.kt:ImageCapturer$camConfig.imageCapture!! + UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$DocumentsContract.createDocument(contentResolver, treeDocumentUri, mimeType(), fileName())!! + UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$contentResolver.openAssetFileDescriptor(uri, "w")!! + UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$obtainOutputUri()!! + UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$origJpegBytes!! + UnsafeCallOnNullableType:InAppGallery.kt:InAppGallery$eInterface.getAttribute(ExifInterface.TAG_DATETIME)!! + UnsafeCallOnNullableType:InAppGallery.kt:InAppGallery$eInterface.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)!! + UnsafeCallOnNullableType:MainActivity.kt:MainActivity$camConfig.camera!! + UnsafeCallOnNullableType:MainActivity.kt:MainActivity$cameraPermissionDialog!! + UnsafeCallOnNullableType:MainActivity.kt:MainActivity$data.encodedPath!! + UnsafeCallOnNullableType:MainActivity.kt:MainActivity$data?.encodedPath!! + UnsafeCallOnNullableType:QrTile.kt:QrTile$getSystemService<KeyguardManager>()!! + UnsafeCallOnNullableType:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier$wr.get()!! + UnsafeCallOnNullableType:SettingsDialog.kt:SettingsDialog$Looper.myLooper()!! + UnsafeCallOnNullableType:SettingsDialog.kt:SettingsDialog.<no name provided>$ev!! + UnsafeCallOnNullableType:SharedPrefs.kt:EphemeralSharedPrefs$key!! + UnsafeCallOnNullableType:SharedPrefs.kt:EphemeralSharedPrefs.Editor$key!! + UnsafeCallOnNullableType:VideoCapturer.kt:VideoCapturer$createRecordingContext(recorder, fileName)!! + UnsafeCallOnNullableType:VideoPlayer.kt:VideoPlayer$getParcelableExtra<Uri>(intent, VIDEO_URI)!! + UnsafeCallOnNullableType:ZoomableImageView.kt:ZoomableImageView$currentInstance.mScaleDetector!! + UnusedPrivateProperty:AutoFinishOnSleep.kt:AutoFinishOnSleep.Companion$private const val TAG = "AutoFinishOnSleep" + UnusedUnaryOperator:BlurBitmap.kt:BlurBitmap$-0x1000000 + UseCheckOrError:ImageSaver.kt:ImageSaver$throw IllegalStateException("unknown imageFormat $imageFormat") + VarCouldBeVal:ZoomBar.kt:ZoomBar$@SuppressLint("InflateParams") private var thumbView: View = LayoutInflater.from(context) .inflate(R.layout.zoom_bar_thumb, null, false) + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var last = PointF() + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var m: FloatArray = FloatArray(9) + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var maxScale = 3f + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var minScale = 1f + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var singleClickHandler = Handler(Looper.getMainLooper()) + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var singleClickRunnable = Runnable { onSingleClick() } + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var start = PointF() + VariableNaming:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$private val ALPHA = 0.7f + + diff --git a/app/detekt-baseline-debugAndroidTest.xml b/app/detekt-baseline-debugAndroidTest.xml new file mode 100644 index 00000000..9686b59c --- /dev/null +++ b/app/detekt-baseline-debugAndroidTest.xml @@ -0,0 +1,11 @@ + + + + + AbstractClassCanBeConcreteClass:EditMediaRegressionTest.kt:EditMediaRegressionTest.HostActivity$HostActivity + AbstractClassCanBeConcreteClass:ShareMediaRegressionTest.kt:ShareMediaRegressionTest.HostActivity$HostActivity + EmptyFunctionBlock:InAppGalleryRegressionTest.kt:InAppGalleryRegressionTest.StalledMediaScan${} + PrintStackTrace:VideoCapturerRegressionTest.kt:VideoCapturerRegressionTest$e + UseCheckOrError:VideoPlayerRegressionTest.kt:VideoPlayerRegressionTest.DeadMediaServiceVideoView$throw IllegalStateException("prepareAsync called in state 0") + + diff --git a/build.gradle.kts b/build.gradle.kts index 3d33ced3..071888d3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,8 @@ +import org.jlleitschuh.gradle.ktlint.KtlintExtension + plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.ktlint) } buildscript { @@ -9,8 +12,42 @@ buildscript { } } +val ktlintCliVersion: String = the() + .named("libs") + .findVersion("ktlint") + .get() + .requiredVersion + +configure { + version.set(ktlintCliVersion) + + filter { + exclude("**/build/**") + } +} + +subprojects { + apply(plugin = "org.jlleitschuh.gradle.ktlint") + + configure { + version.set(ktlintCliVersion) + + filter { + exclude("**/build/**") + } + } +} + allprojects { tasks.withType { - options.compilerArgs.addAll(listOf("-Xlint", "-Xlint:-cast", "-Xlint:-classfile", "-Xlint:-rawtypes", "-Xlint:-serial")) + options.compilerArgs.addAll( + listOf( + "-Xlint", + "-Xlint:-cast", + "-Xlint:-classfile", + "-Xlint:-rawtypes", + "-Xlint:-serial", + ), + ) } } diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 00000000..2656da8b --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,52 @@ +config: + validation: true + warningsAsErrors: true + +complexity: + LongParameterList: + active: false + ignoreDefaultParameters: true + ignoreAnnotated: + - Composable + TooManyFunctions: + allowedFunctionsPerClass: 60 + allowedFunctionsPerFile: 15 + allowedFunctionsPerInterface: 50 + ignoreAnnotatedFunctions: + - Preview + - PreviewLightDark + LongMethod: + ignoreAnnotated: + - Preview + - PreviewLightDark + +coroutines: + InjectDispatcher: + # Stays active — AGENTS.md requires dispatchers to arrive through a qualifier. A Hilt + # module is the one place that names a dispatcher, which is what the exemption covers. + ignoreAnnotated: + - Provides + +naming: + FunctionNaming: + ignoreAnnotated: + - Composable + +style: + AbstractClassCanBeInterface: + ignoreAnnotated: + - Module + + ForbiddenComment: + active: false + + MagicNumber: + ignoreCompanionObjectPropertyDeclaration: true + ignorePropertyDeclaration: true + ignoreAnnotated: + - Composable + + UnusedPrivateFunction: + ignoreAnnotated: + - Preview + - PreviewLightDark diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index edcc3cb6..2ab11d27 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,10 @@ agp = "9.3.1" kotlin = "2.4.10" ksp = "2.3.10" +detekt = "2.0.0-alpha.5" +ktlint = "1.8.0" +ktlint-gradle = "14.2.0" + appcompat = "1.7.1" camerax = { strictly = "1.6.1" } constraintlayout = "2.2.2" @@ -53,3 +57,5 @@ camerax = [ [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +detekt = { id = "dev.detekt", version.ref = "detekt" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 53642c9c..39c6f929 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1937,6 +1937,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2787,6 +2820,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3245,6 +3326,20 @@ + + + + + + + + + + + + + + @@ -3367,6 +3462,11 @@ + + + + + @@ -4088,6 +4188,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4243,11 +4598,601 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5460,6 +6405,23 @@ + + + + + + + + + + + + + + + + + @@ -5570,6 +6532,25 @@ + + + + + + + + + + + + + + + + + + + @@ -5637,6 +6618,23 @@ + + + + + + + + + + + + + + + + + @@ -5679,6 +6677,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5961,6 +6992,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6056,6 +7115,20 @@ + + + + + + + + + + + + + + @@ -6603,6 +7676,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6873,6 +7974,9 @@ + + + @@ -7048,6 +8152,22 @@ + + + + + + + + + + + + + + + + @@ -7090,6 +8210,20 @@ + + + + + + + + + + + + + + @@ -7154,6 +8288,20 @@ + + + + + + + + + + + + + + @@ -7581,6 +8729,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8011,11 +9315,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 21093c15..2aac19dd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,10 @@ +@file:Suppress("UnstableApiUsage") + pluginManagement { repositories { google() mavenCentral() + gradlePluginPortal() } } dependencyResolutionManagement { From 6a304dbb30dd9f418435e9e5c2428752f2ceab9d Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 7 Aug 2026 13:14:49 +0300 Subject: [PATCH 07/14] Fix missing deps verification hashes --- gradle/verification-metadata.xml | 169 +++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 39c6f929..dc7f8807 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -2834,6 +2834,17 @@ + + + + + + + + + + + @@ -2851,6 +2862,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3754,6 +3798,11 @@ + + + + + @@ -7787,6 +7836,17 @@ + + + + + + + + + + + @@ -7876,6 +7936,23 @@ + + + + + + + + + + + + + + + + + @@ -7893,6 +7970,20 @@ + + + + + + + + + + + + + + @@ -7944,6 +8035,23 @@ + + + + + + + + + + + + + + + + + @@ -7964,6 +8072,23 @@ + + + + + + + + + + + + + + + + + @@ -8097,6 +8222,17 @@ + + + + + + + + + + + @@ -8122,6 +8258,17 @@ + + + + + + + + + + + @@ -8246,6 +8393,17 @@ + + + + + + + + + + + @@ -8274,6 +8432,17 @@ + + + + + + + + + + + From 6abffabb4138860e9dc09dbc47fd357d72daaf92 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 12 Aug 2026 00:22:16 +0300 Subject: [PATCH 08/14] Move the camera mode enum into the data layer --- app/config/ktlint/baseline.xml | 253 +++++++++--------- .../camera/BottomTabLayoutRegressionTest.kt | 1 + .../camera/CameraModeTabsRegressionTest.kt | 1 + .../camera/ModeSwitchLatencyRegressionTest.kt | 1 + .../camera/SelfTimerRegressionTest.kt | 1 + .../camera/VideoCapturerRegressionTest.kt | 1 + .../java/app/grapheneos/camera/CamConfig.kt | 29 +- .../camera/data/core/model/CameraMode.kt | 16 ++ .../grapheneos/camera/ui/BottomTabLayout.kt | 2 +- .../camera/ui/activities/MainActivity.kt | 2 +- .../grapheneos/camera/ui/activities/QrTile.kt | 2 +- 11 files changed, 165 insertions(+), 144 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/data/core/model/CameraMode.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index 0d58475d..5b194e29 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -10,11 +10,11 @@ - - - - - + + + + + @@ -37,14 +37,14 @@ - - - - - - - - + + + + + + + + @@ -74,121 +74,118 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt index 4ca9c716..6ae24426 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule +import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.ui.BottomTabLayout import app.grapheneos.camera.ui.activities.MainActivity import org.junit.Assert.assertEquals diff --git a/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt index 784cbdc7..810575ac 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt @@ -8,6 +8,7 @@ import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule +import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.ui.activities.CaptureActivity import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.VideoCaptureActivity diff --git a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt index 293720fc..7cd41cd4 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt @@ -4,6 +4,7 @@ import android.Manifest import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule +import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.ui.activities.MainActivity import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals diff --git a/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt index 6373271e..2d9f828b 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule +import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.VideoOnlyActivity import org.junit.Assert.assertEquals diff --git a/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt index e479b352..c5ad6ce0 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt @@ -22,6 +22,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import app.grapheneos.camera.capturer.deleteStalePendingRecordings +import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.VideoCaptureActivity import app.grapheneos.camera.ui.activities.VideoOnlyActivity diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index 7251ed71..f894f301 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -52,6 +52,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import app.grapheneos.camera.analyzer.QRAnalyzer +import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.ktx.applyPreviewRatio import app.grapheneos.camera.ui.activities.CaptureActivity import app.grapheneos.camera.ui.activities.MainActivity @@ -68,18 +69,6 @@ import java.util.concurrent.ExecutionException import java.util.concurrent.Executors import kotlin.concurrent.thread -// note that enum constant name is used as a name of a SharedPreferences instance -enum class CameraMode(val extensionMode: Int, val uiName: Int) { - QR_SCAN(ExtensionMode.NONE, R.string.qr_scan_mode), - AUTO(ExtensionMode.AUTO, R.string.auto_mode), - FACE_RETOUCH(ExtensionMode.FACE_RETOUCH, R.string.face_retouch_mode), - PORTRAIT(ExtensionMode.BOKEH, R.string.portrait_mode), - NIGHT(ExtensionMode.NIGHT, R.string.night_mode), - HDR(ExtensionMode.HDR, R.string.hdr_mode), - CAMERA(ExtensionMode.NONE, R.string.camera), - VIDEO(ExtensionMode.NONE, R.string.video), -} - @SuppressLint("UnsafeOptInUsageError") class CamConfig(private val mActivity: MainActivity) { @@ -2027,6 +2016,20 @@ class CamConfig(private val mActivity: MainActivity) { } } + @StringRes + private fun tabLabel(mode: CameraMode): Int { + return when (mode) { + CameraMode.QR_SCAN -> R.string.qr_scan_mode + CameraMode.AUTO -> R.string.auto_mode + CameraMode.FACE_RETOUCH -> R.string.face_retouch_mode + CameraMode.PORTRAIT -> R.string.portrait_mode + CameraMode.NIGHT -> R.string.night_mode + CameraMode.HDR -> R.string.hdr_mode + CameraMode.CAMERA -> R.string.camera + CameraMode.VIDEO -> R.string.video + } + } + @SuppressLint("ClickableViewAccessibility") private fun buildTabs() { val tabLayout = mActivity.tabLayout @@ -2042,7 +2045,7 @@ class CamConfig(private val mActivity: MainActivity) { availableModes.forEach { mode -> tabLayout.newTab().let { tab -> - tab.setText(mode.uiName) + tab.setText(tabLabel(mode)) tab.view.setOnTouchListener { _, e -> if (e.action == MotionEvent.ACTION_UP) { diff --git a/app/src/main/java/app/grapheneos/camera/data/core/model/CameraMode.kt b/app/src/main/java/app/grapheneos/camera/data/core/model/CameraMode.kt new file mode 100644 index 00000000..39510326 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/core/model/CameraMode.kt @@ -0,0 +1,16 @@ +package app.grapheneos.camera.data.core.model + +import androidx.camera.extensions.ExtensionMode + +enum class CameraMode( + val extensionMode: Int, +) { + QR_SCAN(ExtensionMode.NONE), + AUTO(ExtensionMode.AUTO), + FACE_RETOUCH(ExtensionMode.FACE_RETOUCH), + PORTRAIT(ExtensionMode.BOKEH), + NIGHT(ExtensionMode.NIGHT), + HDR(ExtensionMode.HDR), + CAMERA(ExtensionMode.NONE), + VIDEO(ExtensionMode.NONE), +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt b/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt index 239ed7b3..91b4db55 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt @@ -7,7 +7,7 @@ import android.view.MotionEvent import android.view.ViewGroup import android.view.animation.AnimationUtils import androidx.core.view.children -import app.grapheneos.camera.CameraMode +import app.grapheneos.camera.data.core.model.CameraMode import com.google.android.material.tabs.TabLayout import kotlin.math.abs import kotlin.math.roundToInt diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt index ea82300f..5bb87c2b 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt @@ -71,13 +71,13 @@ import androidx.core.view.updateLayoutParams import androidx.core.view.updateMargins import app.grapheneos.camera.App import app.grapheneos.camera.CamConfig -import app.grapheneos.camera.CameraMode import app.grapheneos.camera.ITEM_TYPE_IMAGE import app.grapheneos.camera.ITEM_TYPE_VIDEO import app.grapheneos.camera.R import app.grapheneos.camera.capturer.ImageCapturer import app.grapheneos.camera.capturer.VideoCapturer import app.grapheneos.camera.capturer.getVideoThumbnail +import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.shareCapturedItem import app.grapheneos.camera.databinding.ActivityMainBinding import app.grapheneos.camera.databinding.ScanResultDialogBinding diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/QrTile.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/QrTile.kt index ad3ad3f0..5843750d 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/QrTile.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/QrTile.kt @@ -4,7 +4,7 @@ import android.app.KeyguardManager import android.content.Intent import android.os.Bundle import androidx.core.content.getSystemService -import app.grapheneos.camera.CameraMode +import app.grapheneos.camera.data.core.model.CameraMode // Requires integration into the OS, see config_defaultQrCodeComponent in frameworks/base. // From b3733241115da63e8c0d3e89e73a737fbe241deb Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 3 Sep 2026 13:36:06 +0300 Subject: [PATCH 09/14] Store preferences in typed data stores SharedPreferences kept every setting, every SAF grant and the last captured item in untyped files, read and written on whichever thread asked. Replace it with three DataStores serialized as JSON - one for what the owner configured, one for the SAF trees, one for what the app has captured - each opened once in a SingletonComponent module and reached only through its repository. A DataMigration per family carries the legacy keys over on first use. An absent field means "unset", so a value equal to today's default is never written and a later change to that default still reaches existing installs. Installs migrated from SharedPreferences are the exception for the per-mode settings, whose defaults the old code wrote out explicitly. Lockscreen isolation now falls out of the wiring rather than a branch inside a store: an ActivityComponent provider hands a SecureActivity an in-memory snapshot of the owner's settings and SAF grants, while captured-media state stays durable so a photo taken from the lockscreen survives the session. Writes now block until the store has committed, where SharedPreferences.apply() returned before the write landed. --- app/build.gradle.kts | 11 + app/config/ktlint/baseline.xml | 1003 ++++++++--------- app/detekt-baseline-debug.xml | 10 +- .../camera/SafTreeGrantsRegressionTest.kt | 106 +- .../camera/SecurePrefsIsolationTest.kt | 144 ++- .../DurableSettingsPrefsEntryPoint.kt | 16 + .../main/java/app/grapheneos/camera/App.kt | 2 + .../java/app/grapheneos/camera/CamConfig.kt | 728 ++++-------- .../app/grapheneos/camera/CapturedItems.kt | 264 +---- .../grapheneos/camera/capturer/ImageSaver.kt | 3 +- .../camera/capturer/VideoCapturer.kt | 3 +- .../data/core/store/InMemoryDataStore.kt | 27 + .../core/store/JsonPreferenceSerializer.kt | 34 + .../core/store/LegacyCommonPreferences.kt | 48 + .../repository/CapturedItemRepository.kt | 328 ++++++ .../camera/data/media/store/MediaPrefs.kt | 26 + .../data/media/store/MediaPrefsMigration.kt | 66 ++ .../camera/data/media/store/StoragePrefs.kt | 22 + .../data/media/store/StoragePrefsMigration.kt | 59 + .../settings/mapper/CameraSettingsMapper.kt | 108 ++ .../settings/mapper/ModeSettingsMapper.kt | 40 + .../mapper/StoredVideoQualityMapper.kt | 23 + .../data/settings/model/CameraSettings.kt | 41 + .../camera/data/settings/model/GridType.kt | 8 + .../data/settings/model/ModeSettings.kt | 10 + .../data/settings/model/SettingsDefaults.kt | 51 + .../data/settings/model/VideoQualityTitle.kt | 31 + .../settings/repository/SettingsRepository.kt | 120 ++ .../data/settings/store/SettingsPrefs.kt | 177 +++ .../settings/store/SettingsPrefsMigration.kt | 258 +++++ .../di/core/CoroutinesProvidesModule.kt | 31 + .../grapheneos/camera/di/core/Qualifiers.kt | 15 + .../camera/di/media/MediaProvidesModule.kt | 30 + .../DurablePreferencesProvidesModule.kt | 86 ++ .../preferences/PreferencesProvidesModule.kt | 47 + .../camera/di/settings/SettingsBindsModule.kt | 20 + .../di/settings/SettingsMapperBindsModule.kt | 36 + .../app/grapheneos/camera/ui/CustomGrid.kt | 7 +- .../grapheneos/camera/ui/SettingsDialog.kt | 66 +- .../camera/ui/activities/InAppGallery.kt | 11 +- .../camera/ui/activities/MainActivity.kt | 20 +- .../camera/ui/activities/MoreSettings.kt | 3 +- .../ui/activities/MoreSettingsSecure.kt | 2 +- .../camera/ui/activities/SecureActivity.kt | 10 +- .../ui/activities/SecureCaptureActivity.kt | 12 +- .../ui/activities/SecureMainActivity.kt | 8 - .../app/grapheneos/camera/util/SharedPrefs.kt | 166 --- .../java/app/grapheneos/camera/util/Utils.kt | 3 +- .../camera/data/core/InMemoryDataStoreTest.kt | 62 + .../data/core/JsonPreferenceSerializerTest.kt | 64 ++ .../data/core/LegacyMigrationOwnershipTest.kt | 145 +++ .../camera/data/core/LegacyPreferences.kt | 125 ++ .../data/media/CapturedItemRepositoryTest.kt | 346 ++++++ .../data/media/MediaPrefsMigrationTest.kt | 184 +++ .../data/media/StoragePrefsMigrationTest.kt | 135 +++ .../data/settings/CameraSettingsMapperTest.kt | 40 + .../settings/SettingsPrefsMigrationTest.kt | 331 ++++++ .../data/settings/SettingsRepositoryTest.kt | 412 +++++++ .../data/settings/SettingsWireFormatTest.kt | 196 ++++ .../data/settings/VideoQualityTitleTest.kt | 41 + .../mapper/ModeSettingsMapperImplTest.kt | 81 ++ .../StoredVideoQualityMapperImplTest.kt | 52 + .../PreferencesProvidesModuleTest.kt | 130 +++ .../camera/util/EphemeralSharedPrefsTest.kt | 122 -- build.gradle.kts | 3 + gradle/libs.versions.toml | 15 + gradle/verification-metadata.xml | 730 +++++++++++- 67 files changed, 5745 insertions(+), 1809 deletions(-) create mode 100644 app/src/debug/java/app/grapheneos/camera/di/preferences/DurableSettingsPrefsEntryPoint.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/core/store/InMemoryDataStore.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/core/store/JsonPreferenceSerializer.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/core/store/LegacyCommonPreferences.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/media/repository/CapturedItemRepository.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefs.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefsMigration.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefs.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefsMigration.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/mapper/CameraSettingsMapper.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/mapper/ModeSettingsMapper.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/mapper/StoredVideoQualityMapper.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/model/CameraSettings.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/model/GridType.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/model/ModeSettings.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/model/SettingsDefaults.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/model/VideoQualityTitle.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsRepository.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefs.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefsMigration.kt create mode 100644 app/src/main/java/app/grapheneos/camera/di/core/CoroutinesProvidesModule.kt create mode 100644 app/src/main/java/app/grapheneos/camera/di/core/Qualifiers.kt create mode 100644 app/src/main/java/app/grapheneos/camera/di/media/MediaProvidesModule.kt create mode 100644 app/src/main/java/app/grapheneos/camera/di/preferences/DurablePreferencesProvidesModule.kt create mode 100644 app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt create mode 100644 app/src/main/java/app/grapheneos/camera/di/settings/SettingsBindsModule.kt create mode 100644 app/src/main/java/app/grapheneos/camera/di/settings/SettingsMapperBindsModule.kt delete mode 100644 app/src/main/java/app/grapheneos/camera/util/SharedPrefs.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/core/InMemoryDataStoreTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/core/JsonPreferenceSerializerTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/core/LegacyMigrationOwnershipTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/core/LegacyPreferences.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/media/CapturedItemRepositoryTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/media/MediaPrefsMigrationTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/media/StoragePrefsMigrationTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/settings/CameraSettingsMapperTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/settings/SettingsPrefsMigrationTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/settings/SettingsRepositoryTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/settings/SettingsWireFormatTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/settings/VideoQualityTitleTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/settings/mapper/ModeSettingsMapperImplTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/settings/mapper/StoredVideoQualityMapperImplTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModuleTest.kt delete mode 100644 app/src/test/java/app/grapheneos/camera/util/EphemeralSharedPrefsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9bd88bf6..4539d89b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,6 +14,9 @@ if (useKeystoreProperties) { plugins { alias(libs.plugins.android.application) alias(libs.plugins.detekt) + alias(libs.plugins.hilt) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) } detekt { @@ -164,6 +167,14 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.androidx.datastore) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.bundles.camerax) implementation(libs.zxing.core) diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index 5b194e29..a5b2b5b4 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -1,12 +1,12 @@ - - - - - - + + + + + + @@ -26,12 +26,6 @@ - - - - - - @@ -54,15 +48,15 @@ - - - - - - - - - + + + + + + + + + @@ -73,187 +67,124 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + @@ -352,48 +283,48 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -401,38 +332,38 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -483,16 +414,16 @@ - - - - - - - - - - + + + + + + + + + + @@ -511,82 +442,82 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + - @@ -646,175 +577,179 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - + + @@ -883,47 +818,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index 266f2311..6be13724 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -29,7 +29,7 @@ HasPlatformType:InAppGallery.kt:InAppGallery$val asyncImageLoader = Executors.newSingleThreadExecutor() HasPlatformType:InAppGallery.kt:InAppGallery$val asyncLoaderOfCapturedItems = Executors.newSingleThreadExecutor() HasPlatformType:MainActivity.kt:MainActivity$val thumbnailLoaderExecutor = Executors.newSingleThreadExecutor() - HasPlatformType:SharedPrefs.kt:EphemeralSharedPrefs.Editor$val thread = Thread.currentThread() + HasPlatformType:EphemeralSharedPrefs.kt:EphemeralSharedPrefs.Editor$val thread = Thread.currentThread() ImplicitDefaultLocale:QRAnalyzer.kt:QRAnalyzer$"%.02f".format(fps) ImplicitDefaultLocale:ZoomBar.kt:ZoomBar$String.format("%.1fx", zoomRatio) InstanceOfCheckForException:CamConfig.kt:CamConfig$exception !is IllegalArgumentException @@ -121,8 +121,8 @@ MaxLineLength:CapturedItems.kt:CapturedItems$val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME) MaxLineLength:MainActivity.kt:MainActivity$if (cameraPermissionDialog != null && cameraPermissionDialog!!.isShowing) cameraPermissionDialog!!.cancel() MaxLineLength:SettingsDialog.kt:SettingsDialog$mActivity.showMessage("Enabling audio while recording is not currently supported when it was disabled at the start") - MaxLineLength:SharedPrefs.kt:EphemeralSharedPrefs$override - MaxLineLength:SharedPrefs.kt:fun + MaxLineLength:EphemeralSharedPrefs.kt:EphemeralSharedPrefs$override + MaxLineLength:EphemeralSharedPrefs.kt:fun NestedBlockDepth:CapturedItems.kt:CapturedItems$private fun migratePreviousUris NestedBlockDepth:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails NewLineAtEndOfFile:GSlideTransformer.kt:app.grapheneos.camera.GSlideTransformer.kt @@ -214,8 +214,8 @@ UnsafeCallOnNullableType:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier$wr.get()!! UnsafeCallOnNullableType:SettingsDialog.kt:SettingsDialog$Looper.myLooper()!! UnsafeCallOnNullableType:SettingsDialog.kt:SettingsDialog.<no name provided>$ev!! - UnsafeCallOnNullableType:SharedPrefs.kt:EphemeralSharedPrefs$key!! - UnsafeCallOnNullableType:SharedPrefs.kt:EphemeralSharedPrefs.Editor$key!! + UnsafeCallOnNullableType:EphemeralSharedPrefs.kt:EphemeralSharedPrefs$key!! + UnsafeCallOnNullableType:EphemeralSharedPrefs.kt:EphemeralSharedPrefs.Editor$key!! UnsafeCallOnNullableType:VideoCapturer.kt:VideoCapturer$createRecordingContext(recorder, fileName)!! UnsafeCallOnNullableType:VideoPlayer.kt:VideoPlayer$getParcelableExtra<Uri>(intent, VIDEO_URI)!! UnsafeCallOnNullableType:ZoomableImageView.kt:ZoomableImageView$currentInstance.mScaleDetector!! diff --git a/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt index 1893d21e..32bd0683 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt @@ -1,14 +1,17 @@ package app.grapheneos.camera +import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.provider.DocumentsContract import android.provider.MediaStore import androidx.test.ext.junit.runners.AndroidJUnit4 -import app.grapheneos.camera.CamConfig.SettingValues -import app.grapheneos.camera.util.EphemeralSharedPrefs -import app.grapheneos.camera.util.edit +import androidx.test.platform.app.InstrumentationRegistry +import app.grapheneos.camera.data.core.store.InMemoryDataStore +import app.grapheneos.camera.data.media.repository.CapturedItemRepositoryImpl +import app.grapheneos.camera.data.media.store.MediaPrefs +import app.grapheneos.camera.data.media.store.StoragePrefs +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -31,43 +34,47 @@ class SafTreeGrantsRegressionTest { return DocumentsContract.buildTreeDocumentUri(authority, "primary:$name") } - // Only an in-memory SharedPreferences here; what it holds is the real tracked list. - private fun prefs() = EphemeralSharedPrefs(Build.VERSION.SDK_INT) + private val context: Context = InstrumentationRegistry + .getInstrumentation() + .targetContext + .applicationContext - /** What CamConfig.storageLocation records when the user picks a directory. */ - private fun pickStorageLocation(prefs: EphemeralSharedPrefs, treeUri: Uri) { - val current = prefs.getString( - SettingValues.Key.STORAGE_LOCATION, SettingValues.Default.STORAGE_LOCATION - )!! - if (current != SettingValues.Default.STORAGE_LOCATION) { - CapturedItems.savePreviousSafTree(Uri.parse(current), prefs) - } - prefs.edit { - putString(SettingValues.Key.STORAGE_LOCATION, treeUri.toString()) - } + private fun session(): CapturedItemRepositoryImpl { + return CapturedItemRepositoryImpl( + storagePrefs = InMemoryDataStore(StoragePrefs()), + mediaPrefs = InMemoryDataStore(MediaPrefs()), + context = context, + ) + } + + /** The write the app makes when the user picks a directory to save captures in. */ + private fun pickStorageLocation(session: CapturedItemRepositoryImpl, treeUri: Uri) { + runBlocking { session.setStorageLocation(treeUri.toString()) } } /** The regression itself: the directory pushed off the tracked list is the one to release. */ @Test fun theTreeThatFallsOffTheTrackedListIsReleased() { - val prefs = prefs() + val session = session() val picked = (0..CapturedItems.MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES + 1) .map { tree("dir$it") } - picked.forEach { pickStorageLocation(prefs, it) } + picked.forEach { pickStorageLocation(session, it) } - val tracked = CapturedItems.getSafTrees(prefs) + val tracked = runBlocking { session.trackedSafTrees() } assertEquals(CapturedItems.MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES + 1, tracked.size) picked.take(picked.size - tracked.size).forEach { assertEquals( it.toString(), readAndWrite, - CapturedItems.safTreeFlagsToRelease(it, true, true, tracked), + CapturedItems.safTreeFlagsToRelease(it, isRead = true, isWrite = true, tracked), ) } tracked.forEach { assertEquals( - it.toString(), 0, CapturedItems.safTreeFlagsToRelease(it, true, true, tracked) + it.toString(), + 0, + CapturedItems.safTreeFlagsToRelease(it, isRead = true, isWrite = true, tracked), ) } } @@ -75,14 +82,30 @@ class SafTreeGrantsRegressionTest { /** A directory the app still lists keeps its grant, whether it is the current one or a past one. */ @Test fun trackedTreesKeepTheirGrants() { - val prefs = prefs() - pickStorageLocation(prefs, tree("previous")) - pickStorageLocation(prefs, tree("current")) + val session = session() + pickStorageLocation(session, tree("previous")) + pickStorageLocation(session, tree("current")) - val tracked = CapturedItems.getSafTrees(prefs) + val tracked = runBlocking { session.trackedSafTrees() } assertEquals(listOf(tree("current"), tree("previous")), tracked) - assertEquals(0, CapturedItems.safTreeFlagsToRelease(tree("current"), true, true, tracked)) - assertEquals(0, CapturedItems.safTreeFlagsToRelease(tree("previous"), true, true, tracked)) + assertEquals( + 0, + CapturedItems.safTreeFlagsToRelease( + tree("current"), + isRead = true, + isWrite = true, + tracked, + ), + ) + assertEquals( + 0, + CapturedItems.safTreeFlagsToRelease( + tree("previous"), + isRead = true, + isWrite = true, + tracked, + ), + ) } /** Persisted grants that are not trees belong to some other feature, not to storage locations. */ @@ -91,10 +114,16 @@ class SafTreeGrantsRegressionTest { val tracked = emptyList() val document = DocumentsContract.buildDocumentUri(authority, "primary:DCIM/IMG_1.jpg") - assertEquals(0, CapturedItems.safTreeFlagsToRelease(document, true, true, tracked)) + assertEquals( + 0, + CapturedItems.safTreeFlagsToRelease(document, isRead = true, isWrite = true, tracked), + ) val mediaStore = MediaStore.Images.Media.EXTERNAL_CONTENT_URI - assertEquals(0, CapturedItems.safTreeFlagsToRelease(mediaStore, true, true, tracked)) + assertEquals( + 0, + CapturedItems.safTreeFlagsToRelease(mediaStore, isRead = true, isWrite = true, tracked), + ) } /** The release covers exactly the modes the grant holds, and a grant holding none is skipped. */ @@ -105,15 +134,24 @@ class SafTreeGrantsRegressionTest { assertEquals( Intent.FLAG_GRANT_READ_URI_PERMISSION, - CapturedItems.safTreeFlagsToRelease(untracked, true, false, tracked), + CapturedItems.safTreeFlagsToRelease(untracked, isRead = true, isWrite = false, tracked), ) assertEquals( Intent.FLAG_GRANT_WRITE_URI_PERMISSION, - CapturedItems.safTreeFlagsToRelease(untracked, false, true, tracked), + CapturedItems.safTreeFlagsToRelease(untracked, isRead = false, isWrite = true, tracked), + ) + assertEquals( + readAndWrite, + CapturedItems.safTreeFlagsToRelease(untracked, isRead = true, isWrite = true, tracked), ) assertEquals( - readAndWrite, CapturedItems.safTreeFlagsToRelease(untracked, true, true, tracked) + 0, + CapturedItems.safTreeFlagsToRelease( + untracked, + isRead = false, + isWrite = false, + tracked, + ), ) - assertEquals(0, CapturedItems.safTreeFlagsToRelease(untracked, false, false, tracked)) } } diff --git a/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt index 0529946e..d00eff71 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt @@ -2,29 +2,32 @@ package app.grapheneos.camera import android.Manifest import android.content.Context -import android.content.SharedPreferences +import androidx.datastore.core.DataStore import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.di.preferences.DurableSettingsPrefsEntryPoint import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.SecureMainActivity +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith /** - * A lockscreen session may read the owner's settings but must never write them: whoever picks - * up a locked phone would otherwise be able to change what the owner sees after unlocking. - * SecureMainActivity enforces this by overriding getSharedPreferences() to return an ephemeral - * clone, and CamConfig obtains its preferences through the activity — rather than through the - * application context — precisely so it inherits that. - * - * A settings repository injected with the application context would satisfy every other test - * in this suite and silently undo it. + * Asserts the isolation through the repository the activities actually got. A binding that handed a + * secure session the owner's store — or handed it a fresh copy on every lookup, so its own changes + * were silently dropped — would satisfy every other test in this suite. */ @RunWith(AndroidJUnit4::class) class SecurePrefsIsolationTest { @@ -42,90 +45,123 @@ class SecurePrefsIsolationTest { .targetContext .applicationContext - private fun persistentPrefs(): SharedPreferences { - return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private fun asTheOwner(block: (SettingsRepository) -> Unit) { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> block(activity.settingsRepository) } + } } - @After - fun removeProbeKey() { - persistentPrefs().edit().remove(PROBE_KEY).commit() + private val durableSettings: DataStore by lazy { + EntryPointAccessors + .fromApplication(context, DurableSettingsPrefsEntryPoint::class.java) + .settingsPrefs() } - @Test - fun theSecureActivityDoesNotHandOutThePersistentPrefs() { - ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> - scenario.onActivity { activity -> - assertNotSame( - "SecureMainActivity handed out the persistent preferences — a locked" + - " session can now overwrite the owner's settings", - persistentPrefs(), - activity.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), - ) - } - } + private lateinit var ownersSettings: SettingsPrefs + + private fun stored(): SettingsPrefs { + return runBlocking { durableSettings.data.first() } + } + + @Before + fun rememberOwnersSettings() { + ownersSettings = stored() + } + + @After + fun restoreOwnersSettings() { + runBlocking { durableSettings.updateData { ownersSettings } } } @Test fun writesInASecureSessionDoNotChangeThePersistentPrefs() { - persistentPrefs().edit().putInt(PROBE_KEY, 1).commit() + asTheOwner { repository -> + runBlocking { repository.update { it.copy(photoQuality = OWNERS_QUALITY) } } + } ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> scenario.onActivity { activity -> - activity - .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .edit() - .putInt(PROBE_KEY, 2) - .commit() + runBlocking { + activity.settingsRepository.update { it.copy(photoQuality = SESSIONS_QUALITY) } + } } } assertEquals( - "A secure session wrote through to the persistent preferences", - 1, - persistentPrefs().getInt(PROBE_KEY, -1), + "A secure session wrote through to the owner's preferences", + OWNERS_QUALITY, + stored().common.photoQuality, ) } @Test fun aSecureSessionStillReadsTheOwnersSettings() { - persistentPrefs().edit().putInt(PROBE_KEY, 3).commit() + asTheOwner { repository -> + runBlocking { repository.update { it.copy(photoQuality = OWNERS_QUALITY) } } + } ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> scenario.onActivity { activity -> assertEquals( "The isolation must be one-way: a lockscreen session still honours the" + " settings the owner chose", - 3, - activity - .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .getInt(PROBE_KEY, -1), + OWNERS_QUALITY, + runBlocking { activity.settingsRepository.settings.first() }.photoQuality, ) } } } + /** A session handed a fresh copy on every lookup would read the owner's value back. */ + @Test + fun aSecureSessionKeepsItsModeSettingsToItselfAndThenKeepsThem() { + asTheOwner { repository -> + runBlocking { + repository.selectMode(mode = MODE, isFrontFacing = false) + repository.setGeoTagging(false) + } + } + + ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val repository = activity.settingsRepository + + val slotted = runBlocking { + repository.selectMode(mode = MODE, isFrontFacing = false) + repository.setGeoTagging(true) + repository.selectMode(mode = MODE, isFrontFacing = false) + } + + assertTrue( + "The session lost its own mode-scoped write, so it was handed a second copy" + + " of the owner's preferences instead of the one it had been changing", + slotted.geoTagging, + ) + } + } + + assertEquals( + "A secure session wrote through to the owner's mode preferences", + false, + stored().modes[MODE.name]?.geoTagging, + ) + } + @Test fun theRegularActivityDoesWriteThePersistentPrefs() { // The mirror of the tests above: if this ever fails, they would pass for the wrong // reason — because nothing writes preferences at all. - ActivityScenario.launch(MainActivity::class.java).use { scenario -> - scenario.onActivity { activity -> - activity - .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .edit() - .putInt(PROBE_KEY, 7) - .commit() - } + asTheOwner { repository -> + runBlocking { repository.update { it.copy(photoQuality = SESSIONS_QUALITY) } } } - assertEquals(7, persistentPrefs().getInt(PROBE_KEY, -1)) + assertEquals(SESSIONS_QUALITY, stored().common.photoQuality) } private companion object { - // CamConfig.COMMON_SHARED_PREFS_NAME - const val PREFS_NAME = "commons" + val MODE = CameraMode.VIDEO - /** Not a real setting, so a failed run cannot corrupt the app's configuration. */ - const val PROBE_KEY = "securePrefsIsolationProbe" + const val OWNERS_QUALITY = 71 + const val SESSIONS_QUALITY = 42 } } diff --git a/app/src/debug/java/app/grapheneos/camera/di/preferences/DurableSettingsPrefsEntryPoint.kt b/app/src/debug/java/app/grapheneos/camera/di/preferences/DurableSettingsPrefsEntryPoint.kt new file mode 100644 index 00000000..559d54dd --- /dev/null +++ b/app/src/debug/java/app/grapheneos/camera/di/preferences/DurableSettingsPrefsEntryPoint.kt @@ -0,0 +1,16 @@ +package app.grapheneos.camera.di.preferences + +import androidx.datastore.core.DataStore +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.di.core.DurablePreferences +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@EntryPoint +@InstallIn(SingletonComponent::class) +internal interface DurableSettingsPrefsEntryPoint { + + @DurablePreferences + fun settingsPrefs(): DataStore +} diff --git a/app/src/main/java/app/grapheneos/camera/App.kt b/app/src/main/java/app/grapheneos/camera/App.kt index 7d59a1d8..94559026 100644 --- a/app/src/main/java/app/grapheneos/camera/App.kt +++ b/app/src/main/java/app/grapheneos/camera/App.kt @@ -15,9 +15,11 @@ import androidx.appcompat.app.AppCompatActivity import app.grapheneos.camera.capturer.deleteStalePendingRecordings import app.grapheneos.camera.ui.activities.MainActivity import com.google.android.material.color.DynamicColors +import dagger.hilt.android.HiltAndroidApp import java.util.concurrent.TimeUnit import kotlin.concurrent.thread +@HiltAndroidApp class App : Application() { companion object { diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index f894f301..e39e3ff2 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -1,8 +1,6 @@ package app.grapheneos.camera import android.annotation.SuppressLint -import android.content.Context -import android.content.SharedPreferences import android.hardware.camera2.CameraCharacteristics import android.net.Uri import android.os.Build @@ -53,6 +51,13 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import app.grapheneos.camera.analyzer.QRAnalyzer import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.media.repository.CapturedItemRepository +import app.grapheneos.camera.data.settings.model.CameraSettings +import app.grapheneos.camera.data.settings.model.GridType +import app.grapheneos.camera.data.settings.model.ModeSettings +import app.grapheneos.camera.data.settings.model.SettingsDefaults +import app.grapheneos.camera.data.settings.model.focusTimeoutLabel +import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.ktx.applyPreviewRatio import app.grapheneos.camera.ui.activities.CaptureActivity import app.grapheneos.camera.ui.activities.MainActivity @@ -62,113 +67,24 @@ import app.grapheneos.camera.ui.activities.SecureMainActivity import app.grapheneos.camera.ui.activities.VideoCaptureActivity import app.grapheneos.camera.ui.activities.VideoOnlyActivity import app.grapheneos.camera.ui.showIgnoringShortEdgeMode -import app.grapheneos.camera.util.edit import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.zxing.BarcodeFormat import java.util.concurrent.ExecutionException import java.util.concurrent.Executors import kotlin.concurrent.thread +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking @SuppressLint("UnsafeOptInUsageError") -class CamConfig(private val mActivity: MainActivity) { - - enum class GridType { - NONE, - THREE_BY_THREE, - FOUR_BY_FOUR, - GOLDEN_RATIO - } - - object SettingValues { - - object Key { - const val SELF_ILLUMINATION = "self_illumination" - const val GEO_TAGGING = "geo_tagging" - const val FLASH_MODE = "flash_mode" - const val GRID = "grid" - - // obsolete, split into WAIT_FOR_FOCUS_LOCK and PHOTO_QUALITY - const val EMPHASIS_ON_QUALITY = "emphasis_on_quality" - const val FOCUS_TIMEOUT = "focus_timeout" - const val VIDEO_QUALITY = "video_quality" - const val ASPECT_RATIO = "aspect_ratio" - const val INCLUDE_AUDIO = "include_audio" - const val ENABLE_EIS = "enable_eis" - const val SCAN = "scan" - const val SCAN_ALL_CODES = "scan_all_codes" - const val SAVE_IMAGE_AS_PREVIEW = "save_image_as_preview" - const val SAVE_VIDEO_AS_PREVIEW = "save_video_as_preview" - - const val STORAGE_LOCATION = "storage_location" - const val PREVIOUS_SAF_TREES = "previous_saf_trees" - - const val LAST_CAPTURED_ITEM_TYPE = "last_captured_item_type" - const val LAST_CAPTURED_ITEM_DATE_STRING = "last_captured_item_date_string" - const val LAST_CAPTURED_ITEM_URI = "last_captured_item_uri" - - const val PHOTO_QUALITY = "photo_quality" - - const val REMOVE_EXIF_AFTER_CAPTURE = "remove_exif_after_capture" - - const val GYROSCOPE_SUGGESTIONS = "gyroscope_suggestions" - - const val CAMERA_SOUNDS = "camera_sounds" - - const val ENABLE_ZSL = "enable_zsl" - - const val SELECT_HIGHEST_RESOLUTION = "select_highest_resolution" - - const val WAIT_FOR_FOCUS_LOCK = "wait_for_focus_lock" - - const val SELF_TIMER_DURATION = "self_timer_duration" - } - - object Default { - - val GRID_TYPE = GridType.NONE - const val GRID_TYPE_INDEX = 0 - - const val ASPECT_RATIO = AspectRatio.RATIO_4_3 - - val VIDEO_QUALITY = Quality.HIGHEST - - const val SELF_ILLUMINATION = false - - const val GEO_TAGGING = false - - const val FLASH_MODE = ImageCapture.FLASH_MODE_OFF - - const val FOCUS_TIMEOUT = "5s" - - const val INCLUDE_AUDIO = true - - const val ENABLE_EIS = true - - const val SCAN_ALL_CODES = false - - const val SAVE_IMAGE_AS_PREVIEW = true - - const val SAVE_VIDEO_AS_PREVIEW = true - - const val STORAGE_LOCATION = "" - - const val PHOTO_QUALITY = 95 - - const val REMOVE_EXIF_AFTER_CAPTURE = true - - const val GYROSCOPE_SUGGESTIONS = false - - const val CAMERA_SOUNDS = true - - const val ENABLE_ZSL = false - - const val SELECT_HIGHEST_RESOLUTION = false - - const val WAIT_FOR_FOCUS_LOCK = false - - const val SELF_TIMER_DURATION = 0 - } - } +class CamConfig( + private val mActivity: MainActivity, + private val settingsRepository: SettingsRepository, + private val capturedItemRepository: CapturedItemRepository, +) { companion object { private const val TAG = "CamConfig" @@ -195,8 +111,6 @@ class CamConfig(private val mActivity: MainActivity) { val DEFAULT_CAMERA_MODE = CameraMode.CAMERA - const val COMMON_SHARED_PREFS_NAME = "commons" - val FRONT_CAMERA_SELECTOR = CameraSelector.Builder() .requireLensFacing(CameraSelector.LENS_FACING_FRONT) .build() @@ -296,36 +210,45 @@ class CamConfig(private val mActivity: MainActivity) { @set:VisibleForTesting var mPlayer = TunePlayer(mActivity) - // note that Activities which implement SecureActivity interface (meaning they are accessible - // from the lock screen) are forced to override getSharedPreferences() - // and return an instance of in-memory EphemeralSharedPrefs, which are based on "real" prefs, - // but never modify them - val commonPref: SharedPreferences = - mActivity.getSharedPreferences(COMMON_SHARED_PREFS_NAME, Context.MODE_PRIVATE) - private lateinit var modePref: SharedPreferences + private var settings: CameraSettings = runBlocking { settingsRepository.settings.first() } + + private var currentStorageLocation: String = runBlocking { + capturedItemRepository.storageLocation.first() + } + + private var modeSettings: ModeSettings = ModeSettings() + + private val preferencesScope = CoroutineScope(Dispatchers.Main.immediate) var lastCapturedItem: CapturedItem? = null init { - if (mActivity !is SecureActivity) { - CapturedItems.init(mActivity, this) - fetchLastCapturedItemFromSharedPrefs() + preferencesScope.launch(Dispatchers.Main.immediate) { + settingsRepository.settings.collect { settings = it } } - } - fun fetchLastCapturedItemFromSharedPrefs() { - val type = commonPref.getInt(SettingValues.Key.LAST_CAPTURED_ITEM_TYPE, -1) - val dateStr = commonPref.getString(SettingValues.Key.LAST_CAPTURED_ITEM_DATE_STRING, null) - val uri = commonPref.getString(SettingValues.Key.LAST_CAPTURED_ITEM_URI, null) + preferencesScope.launch(Dispatchers.Main.immediate) { + capturedItemRepository.storageLocation.collect { currentStorageLocation = it } + } - var item: CapturedItem? = null - if (dateStr != null && uri != null) { - val skip = type == ITEM_TYPE_IMAGE && mActivity is VideoOnlyActivity - if (!skip) { - item = CapturedItem(type, dateStr, Uri.parse(uri)) + if (mActivity !is SecureActivity) { + runBlocking { + capturedItemRepository.migrateStoredCaptures(::updateLastCapturedItem) + capturedItemRepository.releaseUntrackedSafTrees() } + fetchLastCapturedItem() } - lastCapturedItem = item + } + + fun onDestroy() { + preferencesScope.cancel() + } + + fun fetchLastCapturedItem() { + val item = runBlocking { capturedItemRepository.lastCapturedItem() } + val skip = item?.type == ITEM_TYPE_IMAGE && mActivity is VideoOnlyActivity + + lastCapturedItem = if (skip) null else item } @@ -376,17 +299,14 @@ class CamConfig(private val mActivity: MainActivity) { } else -> { - commonPref.getInt( - SettingValues.Key.ASPECT_RATIO, - SettingValues.Default.ASPECT_RATIO - ) + settings.aspectRatio } } } set(value) { - val editor = commonPref.edit() - editor.putInt(SettingValues.Key.ASPECT_RATIO, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(aspectRatio = value) } + } } var lensFacing = DEFAULT_LENS_FACING @@ -395,101 +315,75 @@ class CamConfig(private val mActivity: MainActivity) { .requireLensFacing(DEFAULT_LENS_FACING) .build() - var gridType: GridType = SettingValues.Default.GRID_TYPE + var gridType: GridType + get() { + return settings.gridType + } set(value) { - val editor = commonPref.edit() - editor.putInt(SettingValues.Key.GRID, GridType.values().indexOf(value)) - editor.apply() - - field = value + settings = runBlocking { + settingsRepository.update { it.copy(gridType = value) } + } } - var videoQuality: Quality = SettingValues.Default.VIDEO_QUALITY + var videoQuality: Quality get() { - return if (modePref.contains(videoQualityKey)) { - mActivity.settingsDialog.titleToQuality( - modePref.getString(videoQualityKey, "")!! - ) - } else { - SettingValues.Default.VIDEO_QUALITY - } + return modeSettings.videoQuality } set(value) { - val option = mActivity.settingsDialog.videoQualitySpinner.selectedItem as String + runBlocking { + settingsRepository.setVideoQuality(value) + }?.let { modeSettings = it } + } - modePref.edit { - putString(videoQualityKey, option) - } + var flashMode: Int = SettingsDefaults.FLASH_MODE + set(value) { + runBlocking { + settingsRepository.setFlashMode(value) + }?.let { modeSettings = it } field = value + imageCapture?.flashMode = value + mActivity.settingsDialog.updateFlashMode() } - private val videoQualityKey: String + var focusTimeout: Long get() { - - val pf = if (lensFacing == CameraSelector.LENS_FACING_FRONT) { - "FRONT" - } else { - "BACK" - } - - return "${SettingValues.Key.VIDEO_QUALITY}_$pf" + return settings.focusTimeoutSeconds } - - var flashMode: Int - get() = if (imageCapture != null) imageCapture!!.flashMode else - SettingValues.Default.FLASH_MODE - set(flashMode) { - - if (::modePref.isInitialized) { - modePref.edit { - putInt(SettingValues.Key.FLASH_MODE, flashMode) - } + set(value) { + settings = runBlocking { + settingsRepository.update { it.copy(focusTimeoutSeconds = value) } } - - imageCapture?.flashMode = flashMode - mActivity.settingsDialog.updateFlashMode() } - var focusTimeout = 5L + var selfTimerDuration: Int + get() { + return settings.selfTimerDurationSeconds + } set(value) { - val option = if (value == 0L) { - "Off" - } else { - "${value}s" + settings = runBlocking { + settingsRepository.update { it.copy(selfTimerDurationSeconds = value) } } - - val editor = commonPref.edit() - editor.putString(SettingValues.Key.FOCUS_TIMEOUT, option) - editor.apply() - - field = value } var enableCameraSounds: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.CAMERA_SOUNDS, - SettingValues.Default.CAMERA_SOUNDS - ) + return settings.enableCameraSounds } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.CAMERA_SOUNDS, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(enableCameraSounds = value) } + } } var scanAllCodes: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.SCAN_ALL_CODES, - SettingValues.Default.SCAN_ALL_CODES - ) + return settings.scanAllCodes } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.SCAN_ALL_CODES, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(scanAllCodes = value) } + } if (isQRMode) { if (value) { @@ -510,133 +404,98 @@ class CamConfig(private val mActivity: MainActivity) { var includeAudio: Boolean get() { - return mActivity.settingsDialog.includeAudioToggle.isChecked + return settings.includeAudio } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.INCLUDE_AUDIO, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(includeAudio = value) } + } mActivity.settingsDialog.includeAudioToggle.isChecked = value } var enableEIS: Boolean get() { - return mActivity.settingsDialog.enableEISToggle.isChecked + return settings.enableEis } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.ENABLE_EIS, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(enableEis = value) } + } mActivity.settingsDialog.enableEISToggle.isChecked = value } var enableZsl: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.ENABLE_ZSL, - SettingValues.Default.ENABLE_ZSL - ) + return settings.enableZsl } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.ENABLE_ZSL, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(enableZsl = value) } + } } var saveImageAsPreviewed: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.SAVE_IMAGE_AS_PREVIEW, - SettingValues.Default.SAVE_IMAGE_AS_PREVIEW - ) + return settings.saveImageAsPreviewed } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.SAVE_IMAGE_AS_PREVIEW, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(saveImageAsPreviewed = value) } + } } var saveVideoAsPreviewed: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.SAVE_VIDEO_AS_PREVIEW, - SettingValues.Default.SAVE_VIDEO_AS_PREVIEW - ) + return settings.saveVideoAsPreviewed } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.SAVE_VIDEO_AS_PREVIEW, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(saveVideoAsPreviewed = value) } + } } var storageLocation: String get() { - return commonPref.getString( - SettingValues.Key.STORAGE_LOCATION, - SettingValues.Default.STORAGE_LOCATION - )!! + return currentStorageLocation } set(value) { - val cur = storageLocation - if (cur != SettingValues.Default.STORAGE_LOCATION) { - CapturedItems.savePreviousSafTree(Uri.parse(cur), commonPref) - } - - val editor = commonPref.edit() - editor.putString(SettingValues.Key.STORAGE_LOCATION, value) - editor.apply() + runBlocking { + currentStorageLocation = capturedItemRepository.setStorageLocation(value) - // Strictly after the write: the tree being picked only becomes tracked once it is the - // stored location, and re-picking a tree that savePreviousSafTree() just pushed off the - // tail of the tracked list would otherwise have its grant revoked out from under it. - CapturedItems.releaseUntrackedSafTrees(mActivity, commonPref) + capturedItemRepository.releaseUntrackedSafTrees() + } } var photoQuality: Int get() { - return commonPref.getInt( - SettingValues.Key.PHOTO_QUALITY, - SettingValues.Default.PHOTO_QUALITY - ) + return settings.photoQuality } set(value) { - val editor = commonPref.edit() - editor.putInt(SettingValues.Key.PHOTO_QUALITY, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(photoQuality = value) } + } } var removeExifAfterCapture: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.REMOVE_EXIF_AFTER_CAPTURE, - SettingValues.Default.REMOVE_EXIF_AFTER_CAPTURE - ) + return settings.removeExifAfterCapture } set(value) { - val editor = commonPref.edit() - editor.putBoolean( - SettingValues.Key.REMOVE_EXIF_AFTER_CAPTURE, - value - ) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(removeExifAfterCapture = value) } + } } var gSuggestions: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.GYROSCOPE_SUGGESTIONS, - SettingValues.Default.GYROSCOPE_SUGGESTIONS - ) + return settings.gyroscopeSuggestions } set(value) { - val editor = commonPref.edit() - editor.putBoolean( - SettingValues.Key.GYROSCOPE_SUGGESTIONS, - value - ) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(gyroscopeSuggestions = value) } + } } val isZslSupported: Boolean by lazy { @@ -686,45 +545,24 @@ class CamConfig(private val mActivity: MainActivity) { return mActivity is CaptureActivity } - private fun saveLastCapturedItem(item: CapturedItem, editor: SharedPreferences.Editor) { - editor.putInt(SettingValues.Key.LAST_CAPTURED_ITEM_TYPE, item.type) - editor.putString(SettingValues.Key.LAST_CAPTURED_ITEM_DATE_STRING, item.dateString) - editor.putString(SettingValues.Key.LAST_CAPTURED_ITEM_URI, item.uri.toString()) - } - fun updateLastCapturedItem(item: CapturedItem) { - commonPref.edit { - saveLastCapturedItem(item, this) - } - - if (mActivity is SecureMainActivity) { - // previous call updated ephemeral SharedPreferences that won't be accessible by the - // "regular" MainActivity - mActivity.applicationContext.getSharedPreferences( - COMMON_SHARED_PREFS_NAME, - Context.MODE_PRIVATE - ).edit { - saveLastCapturedItem(item, this) - } - } + runBlocking { capturedItemRepository.saveLastCapturedItem(item) } lastCapturedItem = item } + // Session state rather than the stored value: geo-tagging is only ever on once the permission + // is actually granted, and reloadSettings() is what settles a stored "on" against that. Reading + // the preference back here would resurrect the very stale "on" the coercion exists to drop. var requireLocation: Boolean = false - get() { - return mActivity.settingsDialog.locToggle.isChecked - } set(value) { mActivity.locationCamConfigChanged(value) // A permission result is delivered before the first onResume of an activity the system - // recreated, so this can run before startCamera() has picked the prefs for a mode - if (::modePref.isInitialized) { - modePref.edit { - putBoolean(SettingValues.Key.GEO_TAGGING, value) - } - } + // recreated, so this can run before a mode has been slotted — see modeSettings. + runBlocking { + settingsRepository.setGeoTagging(value) + }?.let { modeSettings = it } mActivity.settingsDialog.locToggle.isChecked = value @@ -733,16 +571,13 @@ class CamConfig(private val mActivity: MainActivity) { var selfIlluminate: Boolean get() { - return modePref.getBoolean( - SettingValues.Key.SELF_ILLUMINATION, - SettingValues.Default.SELF_ILLUMINATION - ) - && lensFacing == CameraSelector.LENS_FACING_FRONT + return modeSettings.selfIllumination && + lensFacing == CameraSelector.LENS_FACING_FRONT } set(value) { - modePref.edit { - putBoolean(SettingValues.Key.SELF_ILLUMINATION, value) - } + runBlocking { + settingsRepository.setSelfIllumination(value) + }?.let { modeSettings = it } mActivity.settingsDialog.selfIlluminationToggle.isChecked = value mActivity.settingsDialog.selfIllumination() @@ -752,10 +587,10 @@ class CamConfig(private val mActivity: MainActivity) { fun setQRScanningFor(format: String, selected: Boolean) { - val formatSRep = "${SettingValues.Key.SCAN}_$format" - - commonPref.edit { - putBoolean(formatSRep, selected) + settings = runBlocking { + settingsRepository.update { + it.withBarcodeFormat(formatName = format, enabled = selected) + } } if (selected) { @@ -775,186 +610,49 @@ class CamConfig(private val mActivity: MainActivity) { qrAnalyzer?.refreshHints() } - fun reloadSettings() { - // pref config needs to be created - modePref.edit { - if (!modePref.contains(SettingValues.Key.FLASH_MODE)) { - putInt(SettingValues.Key.FLASH_MODE, SettingValues.Default.FLASH_MODE) - } - - if (!modePref.contains(SettingValues.Key.GEO_TAGGING)) { - putBoolean(SettingValues.Key.GEO_TAGGING, SettingValues.Default.GEO_TAGGING) - } + private fun slotCurrentMode() { + modeSettings = runBlocking { + settingsRepository.selectMode( + mode = currentMode, + isFrontFacing = lensFacing == CameraSelector.LENS_FACING_FRONT, + ) + } + } - if (isVideoMode) { - mActivity.settingsDialog.reloadQualities() - } + fun reloadSettings() { + slotCurrentMode() - if (lensFacing == CameraSelector.LENS_FACING_FRONT) { - if (!modePref.contains(SettingValues.Key.SELF_ILLUMINATION)) { - putBoolean( - SettingValues.Key.SELF_ILLUMINATION, - SettingValues.Default.SELF_ILLUMINATION - ) - } - } + if (isVideoMode) { + mActivity.settingsDialog.reloadQualities() } - flashMode = modePref.getInt( - SettingValues.Key.FLASH_MODE, - SettingValues.Default.FLASH_MODE - ) + flashMode = modeSettings.flashMode // A stored "on" is written before a permission request resolves, and it outlives a later // revocation, so it cannot be asserted on its own: doing so opened a permission dialog on // startup that the user never asked for. Coercing it here settles the stale value through // the setter, and leaves every dialog in the app originating from an explicit toggle. - requireLocation = modePref.getBoolean( - SettingValues.Key.GEO_TAGGING, - SettingValues.Default.GEO_TAGGING - ) && !(mActivity.applicationContext as App).shouldAskForLocationPermission() - - selfIlluminate = modePref.getBoolean( - SettingValues.Key.SELF_ILLUMINATION, - SettingValues.Default.SELF_ILLUMINATION - ) + requireLocation = modeSettings.geoTagging && + !(mActivity.applicationContext as App).shouldAskForLocationPermission() + + selfIlluminate = modeSettings.selfIllumination mActivity.settingsDialog.showOnlyRelevantSettings() } fun loadSettings() { - - // Create common config. if it's not created - val editor = commonPref.edit() - - if (!commonPref.contains(SettingValues.Key.CAMERA_SOUNDS)) { - editor.putBoolean(SettingValues.Key.CAMERA_SOUNDS, SettingValues.Default.CAMERA_SOUNDS) - } - - // Note: This is a workaround to keep save image/video as previewed 'on' by - // default starting from v73 and 'off' by default for versions before that - // - // If its not a fresh install (before v73) - if (commonPref.contains(SettingValues.Key.SAVE_IMAGE_AS_PREVIEW)) { - // If save video as previewed was not previously set - if (!commonPref.contains(SettingValues.Key.SAVE_VIDEO_AS_PREVIEW)) { - // Explicitly set the value for this setting as false for them - // to ensure consistent behavior - editor.putBoolean( - SettingValues.Key.SAVE_VIDEO_AS_PREVIEW, - false - ) - } - } else { - editor.putBoolean( - SettingValues.Key.SAVE_IMAGE_AS_PREVIEW, - SettingValues.Default.SAVE_IMAGE_AS_PREVIEW - ) - - editor.putBoolean( - SettingValues.Key.SAVE_VIDEO_AS_PREVIEW, - SettingValues.Default.SAVE_VIDEO_AS_PREVIEW - ) - } - - if (!commonPref.contains(SettingValues.Key.GRID)) { - // Index for Grid.values() Default: NONE - editor.putInt(SettingValues.Key.GRID, SettingValues.Default.GRID_TYPE_INDEX) - } - - if (!commonPref.contains(SettingValues.Key.FOCUS_TIMEOUT)) { - editor.putString(SettingValues.Key.FOCUS_TIMEOUT, SettingValues.Default.FOCUS_TIMEOUT) - } - - migrateFromLegacyPhotoQuality() - - if (!commonPref.contains(SettingValues.Key.INCLUDE_AUDIO)) { - editor.putBoolean( - SettingValues.Key.INCLUDE_AUDIO, - SettingValues.Default.INCLUDE_AUDIO - ) - } - - if (!commonPref.contains(SettingValues.Key.ENABLE_EIS)) { - editor.putBoolean( - SettingValues.Key.ENABLE_EIS, - SettingValues.Default.ENABLE_EIS - ) - } - - if (!commonPref.contains(SettingValues.Key.ASPECT_RATIO)) { - editor.putInt( - SettingValues.Key.ASPECT_RATIO, - SettingValues.Default.ASPECT_RATIO - ) - } - - if (!commonPref.contains(SettingValues.Key.SCAN_ALL_CODES)) { - editor.putBoolean( - SettingValues.Key.SCAN_ALL_CODES, - SettingValues.Default.SCAN_ALL_CODES - ) - } - - val qrRep = "${SettingValues.Key.SCAN}_${BarcodeFormat.QR_CODE.name}" - - if (!commonPref.contains(qrRep)) { - for (format in BarcodeFormat.values()) { - val formatSRep = "${SettingValues.Key.SCAN}_${format.name}" - - editor.putBoolean( - formatSRep, - false - ) - } - - editor.putBoolean( - qrRep, - true - ) - } - - - editor.apply() - - gridType = GridType.values()[commonPref.getInt( - SettingValues.Key.GRID, - SettingValues.Default.GRID_TYPE_INDEX - )] - mActivity.settingsDialog.updateGridToggleUI() - commonPref.getString(SettingValues.Key.FOCUS_TIMEOUT, SettingValues.Default.FOCUS_TIMEOUT) - ?.let { - mActivity.settingsDialog.updateFocusTimeout(it) - } + mActivity.settingsDialog.updateFocusTimeout(focusTimeoutLabel(settings.focusTimeoutSeconds)) - aspectRatio = commonPref.getInt( - SettingValues.Key.ASPECT_RATIO, - SettingValues.Default.ASPECT_RATIO - ) + includeAudio = settings.includeAudio - includeAudio = commonPref.getBoolean( - SettingValues.Key.INCLUDE_AUDIO, - SettingValues.Default.INCLUDE_AUDIO - ) - - enableEIS = commonPref.getBoolean( - SettingValues.Key.ENABLE_EIS, - SettingValues.Default.ENABLE_EIS - ) + enableEIS = settings.enableEis allowedFormats.clear() for (format in BarcodeFormat.values()) { - val formatSRep = "${SettingValues.Key.SCAN}_${format.name}" - - val isEnabled = commonPref.getBoolean( - formatSRep, - false - ) - - if (isEnabled) { + if (format.name in settings.enabledBarcodeFormats) { if (format !in allowedFormats) { allowedFormats.add(format) } @@ -982,57 +680,24 @@ class CamConfig(private val mActivity: MainActivity) { var waitForFocusLock: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.WAIT_FOR_FOCUS_LOCK, - SettingValues.Default.WAIT_FOR_FOCUS_LOCK - ) + return settings.waitForFocusLock } set(value) { - commonPref.edit { - putBoolean(SettingValues.Key.WAIT_FOR_FOCUS_LOCK, value) + settings = runBlocking { + settingsRepository.update { it.copy(waitForFocusLock = value) } } } var selectHighestResolution: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.SELECT_HIGHEST_RESOLUTION, - SettingValues.Default.SELECT_HIGHEST_RESOLUTION - ) + return settings.selectHighestResolution } set(value) { - commonPref.edit { - putBoolean(SettingValues.Key.SELECT_HIGHEST_RESOLUTION, value) + settings = runBlocking { + settingsRepository.update { it.copy(selectHighestResolution = value) } } } - fun migrateFromLegacyPhotoQuality() { - // If emphasis on quality/optimization was previously set by the user - if (commonPref.contains(SettingValues.Key.EMPHASIS_ON_QUALITY)) { - // If the photo quality key has not previously been set - if (!commonPref.contains(SettingValues.Key.PHOTO_QUALITY)) { - val optimizeForQuality = - commonPref.getBoolean(SettingValues.Key.EMPHASIS_ON_QUALITY, false) - - photoQuality = if (optimizeForQuality) { - 100 - } else { - 95 - } - } - - // Remove the key to avoid re-execution of the above code - commonPref.edit { - remove(SettingValues.Key.EMPHASIS_ON_QUALITY) - } - } - - if (photoQuality == 0) { - photoQuality = 95; - } - } - - fun toggleTorchState() { isTorchOn = !isTorchOn } @@ -1339,7 +1004,7 @@ class CamConfig(private val mActivity: MainActivity) { } // The quality labels shown in the settings spinner, so that a message about a quality can - // name it exactly the way the user picked it (see SettingsDialog.getTitleFor). + // name it exactly the way the user picked it (see videoQualityTitle). private fun describeQualityFeature(feature: GroupableFeature): String? = when (feature) { GroupableFeatures.UHD_RECORDING -> "2160p (UHD)" GroupableFeatures.FHD_RECORDING -> "1080p (FHD)" @@ -1438,7 +1103,11 @@ class CamConfig(private val mActivity: MainActivity) { mActivity.imageCapturer.cancelPendingCaptureRequest() mActivity.exposureBar.hidePanel() - modePref = mActivity.getSharedPreferences(currentMode.name, Context.MODE_PRIVATE) + slotCurrentMode() + + // Before the builder below reads it: the mode just slotted may store a different flash mode + // than the one that was bound, and the ImageCapture is configured once, at build time. + flashMode = modeSettings.flashMode val rotation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val display = mActivity.display @@ -2144,13 +1813,7 @@ class CamConfig(private val mActivity: MainActivity) { optionNames.add(format.name) - val formatSRep = "${SettingValues.Key.SCAN}_$format" - optionValues.add( - commonPref.getBoolean( - formatSRep, - false - ) - ) + optionValues.add(format.name in settings.enabledBarcodeFormats) } builder.setMultiChoiceItems( @@ -2182,24 +1845,27 @@ class CamConfig(private val mActivity: MainActivity) { } } - commonPref.edit { - for ((index, element) in optionNames.withIndex()) { - - val optionName = element - val optionValue = optionValues[index] + for ((index, optionName) in optionNames.withIndex()) { - val formatSRep = "${SettingValues.Key.SCAN}_$optionName" + val format = BarcodeFormat.valueOf(optionName) - val format = BarcodeFormat.valueOf(optionName) - - if (optionValue) { - if (format !in allowedFormats) - allowedFormats.add(format) - } else { - allowedFormats.remove(format) + if (optionValues[index]) { + if (format !in allowedFormats) { + allowedFormats.add(format) } + } else { + allowedFormats.remove(format) + } + } - putBoolean(formatSRep, optionValue) + settings = runBlocking { + settingsRepository.update { current -> + optionNames.foldIndexed(current) { index, updated, optionName -> + updated.withBarcodeFormat( + formatName = optionName, + enabled = optionValues[index], + ) + } } } @@ -2223,7 +1889,7 @@ class CamConfig(private val mActivity: MainActivity) { fun onStorageLocationNotFound() { // Reverting back to DEFAULT_MEDIA_STORE_CAPTURE_PATH - storageLocation = SettingValues.Default.STORAGE_LOCATION + storageLocation = CapturedItemRepository.MEDIA_STORE_LOCATION val builder = MaterialAlertDialogBuilder(mActivity) .setTitle(R.string.folder_not_found) diff --git a/app/src/main/java/app/grapheneos/camera/CapturedItems.kt b/app/src/main/java/app/grapheneos/camera/CapturedItems.kt index 91417ba0..4fb65b2b 100644 --- a/app/src/main/java/app/grapheneos/camera/CapturedItems.kt +++ b/app/src/main/java/app/grapheneos/camera/CapturedItems.kt @@ -3,26 +3,16 @@ package app.grapheneos.camera import android.annotation.SuppressLint import android.app.Activity import android.content.ActivityNotFoundException -import android.content.ContentResolver import android.content.Intent -import android.content.ContentUris -import android.content.Context -import android.content.SharedPreferences import android.net.Uri import android.os.Parcel import android.os.Parcelable -import android.provider.BaseColumns import android.provider.DocumentsContract -import android.provider.MediaStore import android.util.Log import androidx.annotation.StringRes -import app.grapheneos.camera.CamConfig.SettingValues -import app.grapheneos.camera.util.EphemeralSharedPrefs -import app.grapheneos.camera.util.edit import java.text.ParseException import java.text.SimpleDateFormat import java.util.Locale -import kotlin.jvm.Throws typealias ItemType = Int const val ITEM_TYPE_IMAGE: ItemType = 0 @@ -164,58 +154,12 @@ object CapturedItems { const val MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES = 5 - fun init(ctx: Context, camConfig: CamConfig) { - val prefs = camConfig.commonPref - - val legacyPrefKey = "media_uri_s" - val urisToMigrate = prefs.getString(legacyPrefKey, null) - - if (urisToMigrate != null) { - prefs.edit { - migratePreviousUris(ctx, camConfig, urisToMigrate, this, maybeGetCurentSafTree(prefs)) - remove(legacyPrefKey) - } - } - - releaseUntrackedSafTrees(ctx, prefs) - } - - // A directory the user picks as the storage location is granted to us persistably, which lasts - // until we release it or the app is uninstalled. Trees drop off the tracked list once the user - // has picked enough different directories to push one past - // MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES, and the grant used to stay behind, leaving the app - // with indefinite read/write access to a folder it no longer has any use for. Reconcile the two. - fun releaseUntrackedSafTrees(ctx: Context, prefs: SharedPreferences) { - // A secure session reads a throwaway copy of the preferences, so the tracked list it sees is - // not the durable one and must never drive a durable revoke. - if (prefs is EphemeralSharedPrefs) { - return - } - - val tracked = getSafTrees(prefs) - val resolver = ctx.contentResolver - - resolver.persistedUriPermissions.forEach { permission -> - val uri = permission.uri - val flags = safTreeFlagsToRelease( - uri, permission.isReadPermission, permission.isWritePermission, tracked - ) - if (flags == 0) { - return@forEach - } - - try { - resolver.releasePersistableUriPermission(uri, flags) - } catch (e: Exception) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "unable to release the grant for $uri", e) - } - } - } - } + // save few last SAF trees to include their contents in the gallery + // format: '\0' separated concatenated uri strings, most recent come first + const val SAF_TREE_SEPARATOR = "\u0000" - // Split out of the loop above so that the decision can be tested: an app cannot construct the - // UriPermission the loop reads it from. + // Split out of CapturedItemRepository's release loop so that the decision can be tested: an app + // cannot construct the UriPermission that loop reads it from. internal fun safTreeFlagsToRelease( uri: Uri, isRead: Boolean, isWrite: Boolean, tracked: Collection ): Int { @@ -237,202 +181,6 @@ object CapturedItems { return flags } - @Throws(InterruptedException::class) - fun get(ctx: Context): List { - val resolver = ctx.contentResolver - val list = ArrayList() - - collectMediaStoreItems(resolver, MediaStore.VOLUME_EXTERNAL_PRIMARY, list) - - getSafTrees(ctx.getSharedPreferences(CamConfig.COMMON_SHARED_PREFS_NAME, Context.MODE_PRIVATE)).forEach { - if (Thread.interrupted()) { - // executor is shutting down - throw InterruptedException() - } - collectSafItems(resolver, it, list) - } - - return list.distinct() - } - - private fun collectMediaStoreItems(resolver: ContentResolver, volumeName: String, dest: ArrayList) { - val volumeUri = MediaStore.Files.getContentUri(volumeName) - - val columns = arrayOf(BaseColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME) - val idColumn = 0 - val nameColumn = 1 - - try { - resolver.query(volumeUri, columns, null, null)?.use { - dest.ensureCapacity(it.count) - - while (it.moveToNext()) { - val name = it.getString(nameColumn) - val uri = ContentUris.withAppendedId(volumeUri, it.getLong(idColumn)) - - parseCapturedItem(name, uri)?.let { - dest.add(it) - } - } - } - } catch (e: Exception) { - Log.d(TAG, "unable to collect MediaStore items, volume $volumeName", e) - } - } - - private fun collectSafItems(resolver: ContentResolver, treeUri: Uri, dest: ArrayList) { - val treeId = DocumentsContract.getTreeDocumentId(treeUri) - val childDocumentsUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, treeId) - - val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME) - val idColumn = 0 - val nameColumn = 1 - - try { - resolver.query(childDocumentsUri, columns, null, null)?.use { - dest.ensureCapacity(it.count) - - while (it.moveToNext()) { - val name = it.getString(nameColumn) - val id = it.getString(idColumn) - val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id) - - parseCapturedItem(name, uri)?.let { - dest.add(it) - } - } - } - } catch (e: Exception) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "unable to collect SAF items, treeUri $treeUri", e) - } - } - } - - fun maybeGetCurentSafTree(prefs: SharedPreferences): Uri? { - return prefs.getString(SettingValues.Key.STORAGE_LOCATION, null)?.let { - if (it != SettingValues.Default.STORAGE_LOCATION) { - Uri.parse(it) - } else { - null - } - } - } - - fun getSafTrees(prefs: SharedPreferences): List { - val list = ArrayList() - - maybeGetCurentSafTree(prefs)?.let { - list.add(it) - } - - list.addAll(getPreviousSafTrees(prefs)) - - return list.distinct() - } - - // save few last SAF trees to include their contents in the gallery - // format: '\0' separated concatenated uri strings, most recent come first - - const val SAF_TREE_SEPARATOR = "\u0000" - - fun getPreviousSafTrees(prefs: SharedPreferences): MutableList { - prefs.getString(SettingValues.Key.PREVIOUS_SAF_TREES, null)?.let { - return it.split(SAF_TREE_SEPARATOR).map { Uri.parse(it) }.toMutableList() - } - return ArrayList() - } - - fun savePreviousSafTree(treeUri: Uri, prefs: SharedPreferences) { - val list = getPreviousSafTrees(prefs) - - list.remove(treeUri) - list.add(0, treeUri) - - while (list.size > MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES) { - // list.removeLast() requires API level 35 now due to Java adding it - list.removeAt(list.lastIndex) - } - - prefs.edit { - savePreviousSafTrees(list, this) - } - } - - fun savePreviousSafTrees(trees: List, editor: SharedPreferences.Editor) { - if (trees.isEmpty()) { - return - } - val str = trees.map { it.toString() }.toTypedArray().joinToString(separator = SAF_TREE_SEPARATOR) - editor.putString(SettingValues.Key.PREVIOUS_SAF_TREES, str) - } - - private fun migratePreviousUris(ctx: Context, camConfig: CamConfig, joinedUris: String, editor: SharedPreferences.Editor, currentTreeUri: Uri?) { - val list = ArrayList() - - if (joinedUris.isEmpty()) { - return - } - - var checkedLastCapturedItem = false - - joinedUris.split(";").forEach { uriString -> - val uri = Uri.parse(uriString) - - val authority = uri.authority!! - - if (!checkedLastCapturedItem) { - val columnName = if (authority == MediaStore.AUTHORITY) { - MediaStore.MediaColumns.DISPLAY_NAME - } else { - // SAF - DocumentsContract.Document.COLUMN_DISPLAY_NAME - } - - var fileName: String? = null - - try { - val projection = arrayOf(columnName) - ctx.contentResolver.query(uri, projection, null, null)?.use { - if (it.moveToFirst()) { - fileName = it.getString(0) - } - } - } catch (ignored: Exception) {} - - fileName?.let { - val item = parseCapturedItem(it, uri) - if (item != null) { - camConfig.updateLastCapturedItem(item) - } - } - - checkedLastCapturedItem = true - } - - if (authority == MediaStore.AUTHORITY) { - return@forEach - } - - val treeId = DocumentsContract.getTreeDocumentId(uri) - val treeUri = DocumentsContract.buildTreeDocumentUri(authority, treeId) - - if (treeUri == currentTreeUri || list.contains(treeUri) - // list is small, not worth it to switch to a Set and lose item order - || treeUri.toString().contains(SAF_TREE_SEPARATOR)) - { - return@forEach - } - - list.add(treeUri) - if (list.size == MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES) { - return@forEach - } - } - - savePreviousSafTrees(list, editor) - } - fun parseCapturedItem(fileName: String, uri: Uri): CapturedItem? { val type = if (fileName.startsWith(IMAGE_NAME_PREFIX)) { ITEM_TYPE_IMAGE @@ -452,7 +200,7 @@ object CapturedItems { for (i in prefixLen until end) { val ch = fileName[i] - if ((ch >= '0' && ch <= '9') || ch == '_') { + if ((ch in '0'..'9') || ch == '_') { continue } return null diff --git a/app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt b/app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt index 6477716c..d8f7ce5b 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt @@ -25,6 +25,7 @@ import app.grapheneos.camera.IMAGE_NAME_PREFIX import app.grapheneos.camera.ITEM_TYPE_IMAGE import app.grapheneos.camera.capturer.ImageSaverException.Place import app.grapheneos.camera.clearExif +import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.fixExif import app.grapheneos.camera.util.ImageResizer import app.grapheneos.camera.util.executeIfAlive @@ -284,7 +285,7 @@ class ImageSaver( mainThreadExecutor.execute { imageCapturer.onThumbnailGenerated(bitmap) } } - fun saveToMediaStore() = storageLocation == CamConfig.SettingValues.Default.STORAGE_LOCATION + fun saveToMediaStore() = storageLocation == CapturedItemRepository.MEDIA_STORE_LOCATION private fun dateString() = // it's important to include milliseconds (SSS), otherwise new image may overwrite the previous one diff --git a/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt b/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt index 83819c7d..b919ad3e 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt @@ -32,6 +32,7 @@ import app.grapheneos.camera.CapturedItem import app.grapheneos.camera.ITEM_TYPE_VIDEO import app.grapheneos.camera.R import app.grapheneos.camera.VIDEO_NAME_PREFIX +import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.SecureMainActivity import app.grapheneos.camera.ui.activities.VideoCaptureActivity @@ -106,7 +107,7 @@ class VideoCapturer(private val mActivity: MainActivity) { } else { val storageLocation = camConfig.storageLocation - if (storageLocation == CamConfig.SettingValues.Default.STORAGE_LOCATION) { + if (storageLocation == CapturedItemRepository.MEDIA_STORE_LOCATION) { val contentValues = ContentValues().apply { put(MediaColumns.DISPLAY_NAME, fileName) put(MediaColumns.MIME_TYPE, mimeType) diff --git a/app/src/main/java/app/grapheneos/camera/data/core/store/InMemoryDataStore.kt b/app/src/main/java/app/grapheneos/camera/data/core/store/InMemoryDataStore.kt new file mode 100644 index 00000000..396835e3 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/core/store/InMemoryDataStore.kt @@ -0,0 +1,27 @@ +package app.grapheneos.camera.data.core.store + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class InMemoryDataStore( + initial: T, +) : DataStore { + + private val stored = MutableStateFlow(initial) + + override val data: Flow = stored.asStateFlow() + + private val writes = Mutex() + + override suspend fun updateData(transform: suspend (T) -> T): T { + return writes.withLock { + val updated = transform(stored.value) + stored.value = updated + updated + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/core/store/JsonPreferenceSerializer.kt b/app/src/main/java/app/grapheneos/camera/data/core/store/JsonPreferenceSerializer.kt new file mode 100644 index 00000000..5c26b04e --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/core/store/JsonPreferenceSerializer.kt @@ -0,0 +1,34 @@ +package app.grapheneos.camera.data.core.store + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import java.io.InputStream +import java.io.OutputStream +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +internal class JsonPreferenceSerializer( + private val serializer: KSerializer, + override val defaultValue: T, +) : Serializer { + + override suspend fun readFrom(input: InputStream): T { + return try { + json.decodeFromString(serializer, input.readBytes().decodeToString()) + } catch (e: SerializationException) { + throw CorruptionException("unreadable preferences", e) + } + } + + override suspend fun writeTo(t: T, output: OutputStream) { + output.write(json.encodeToString(serializer, t).encodeToByteArray()) + } + + private companion object { + // Preserve fields written by newer versions across downgrades. + private val json = Json { + ignoreUnknownKeys = true + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/core/store/LegacyCommonPreferences.kt b/app/src/main/java/app/grapheneos/camera/data/core/store/LegacyCommonPreferences.kt new file mode 100644 index 00000000..f779ccfb --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/core/store/LegacyCommonPreferences.kt @@ -0,0 +1,48 @@ +package app.grapheneos.camera.data.core.store + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit + +private const val LEGACY_COMMON_PREFS_NAME = "commons" + +internal const val LEGACY_LAST_CAPTURED_ITEM_TYPE = "last_captured_item_type" +internal const val LEGACY_LAST_CAPTURED_ITEM_DATE_STRING = "last_captured_item_date_string" +internal const val LEGACY_LAST_CAPTURED_ITEM_URI = "last_captured_item_uri" + +internal const val LEGACY_STORAGE_LOCATION = "storage_location" +internal const val LEGACY_PREVIOUS_SAF_TREES = "previous_saf_trees" +internal const val LEGACY_MEDIA_URIS = "media_uri_s" + +// Settings claims every key not explicitly owned by capture or storage. +internal val LEGACY_CAPTURE_KEYS = setOf( + LEGACY_LAST_CAPTURED_ITEM_TYPE, + LEGACY_LAST_CAPTURED_ITEM_DATE_STRING, + LEGACY_LAST_CAPTURED_ITEM_URI, +) + +internal val LEGACY_STORAGE_KEYS = setOf( + LEGACY_STORAGE_LOCATION, + LEGACY_PREVIOUS_SAF_TREES, + LEGACY_MEDIA_URIS, +) + +internal fun legacyCommonPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences(LEGACY_COMMON_PREFS_NAME, Context.MODE_PRIVATE) +} + +internal fun removeLegacyCommonKeys(context: Context, owns: (String) -> Boolean) { + val preferences = legacyCommonPreferences(context) + val owned = preferences.all.keys.filter(owns) + + // An empty edit can recreate the file after another migration deletes it. + if (owned.isNotEmpty()) { + preferences.edit(commit = true) { + owned.forEach { remove(it) } + } + } + + if (preferences.all.isEmpty()) { + context.deleteSharedPreferences(LEGACY_COMMON_PREFS_NAME) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/media/repository/CapturedItemRepository.kt b/app/src/main/java/app/grapheneos/camera/data/media/repository/CapturedItemRepository.kt new file mode 100644 index 00000000..b008db0b --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/repository/CapturedItemRepository.kt @@ -0,0 +1,328 @@ +package app.grapheneos.camera.data.media.repository + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.provider.BaseColumns +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.util.Log +import androidx.core.net.toUri +import androidx.datastore.core.DataStore +import app.grapheneos.camera.BuildConfig +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.CapturedItems +import app.grapheneos.camera.data.media.store.MediaPrefs +import app.grapheneos.camera.data.media.store.StoragePrefs +import app.grapheneos.camera.data.media.store.StoredCapturedItem +import dagger.hilt.android.qualifiers.ActivityContext +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +interface CapturedItemRepository { + + suspend fun lastCapturedItem(): CapturedItem? + + suspend fun saveLastCapturedItem(item: CapturedItem) + + val storageLocation: Flow + + suspend fun setStorageLocation(value: String): String + + suspend fun releaseUntrackedSafTrees() + + suspend fun migrateStoredCaptures(onLastCapturedItem: (CapturedItem) -> Unit) + + suspend fun capturedItems(): List + + companion object { + const val MEDIA_STORE_LOCATION = "" + } +} + +@Suppress("TooManyFunctions") +internal class CapturedItemRepositoryImpl @Inject constructor( + private val storagePrefs: DataStore, + private val mediaPrefs: DataStore, + @ActivityContext private val context: Context, +) : CapturedItemRepository { + + override suspend fun lastCapturedItem(): CapturedItem? { + val stored = mediaPrefs.data.first().lastCapturedItem ?: return null + + return CapturedItem( + type = stored.type, + dateString = stored.dateString, + uri = stored.uri.toUri(), + ) + } + + override suspend fun saveLastCapturedItem(item: CapturedItem) { + mediaPrefs.updateData { + it.copy( + lastCapturedItem = StoredCapturedItem( + type = item.type, + dateString = item.dateString, + uri = item.uri.toString(), + ), + ) + } + } + + override val storageLocation: Flow = storagePrefs.data.map { + it.storageLocation ?: CapturedItemRepository.MEDIA_STORE_LOCATION + } + + override suspend fun setStorageLocation(value: String): String { + val stored = storagePrefs.updateData { prefs -> + val previous = prefs.storageLocation?.takeIf { it.isNotEmpty() } + + when { + previous == null || previous == value -> prefs.copy(storageLocation = value) + else -> { + val trees = prefs.previousSafTrees.mapTo(ArrayList()) { it.toUri() } + val previousTree = previous.toUri() + + trees.remove(previousTree) + trees.add(0, previousTree) + + while (trees.size > CapturedItems.MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES) { + // MutableList.removeLast() resolves to an API 35 Java method. + trees.removeAt(trees.lastIndex) + } + + prefs.copy( + storageLocation = value, + previousSafTrees = trees.map { it.toString() }, + ) + } + } + } + + return stored.storageLocation ?: CapturedItemRepository.MEDIA_STORE_LOCATION + } + + @Suppress("TooGenericExceptionCaught") + override suspend fun releaseUntrackedSafTrees() { + val tracked = trackedSafTrees() + val resolver = context.contentResolver + + resolver.persistedUriPermissions.forEach { permission -> + val uri = permission.uri + val flags = CapturedItems.safTreeFlagsToRelease( + uri, + permission.isReadPermission, + permission.isWritePermission, + tracked, + ) + if (flags == 0) { + return@forEach + } + + try { + resolver.releasePersistableUriPermission(uri, flags) + } catch (e: Exception) { + if (BuildConfig.DEBUG) { + Log.d(CapturedItems.TAG, "unable to release the grant for $uri", e) + } + } + } + } + + override suspend fun migrateStoredCaptures(onLastCapturedItem: (CapturedItem) -> Unit) { + val joinedUris = storagePrefs.data.first().legacyMediaUris ?: return + val trees = legacyTrees(joinedUris = joinedUris, onLastCapturedItem = onLastCapturedItem) + + storagePrefs.updateData { + it.withPreviousSafTrees(trees).copy(legacyMediaUris = null) + } + } + + override suspend fun capturedItems(): List { + val resolver = context.contentResolver + val items = ArrayList() + + collectMediaStoreItems(resolver, MediaStore.VOLUME_EXTERNAL_PRIMARY, items) + + trackedSafTrees().forEach { + if (Thread.interrupted()) { + throw InterruptedException() + } + collectSafItems(resolver, it, items) + } + + return items.distinct() + } + + internal suspend fun trackedSafTrees(): List { + val prefs = storagePrefs.data.first() + val trees = ArrayList() + + prefs.storageLocation + ?.takeIf { it.isNotEmpty() } + ?.let { trees.add(it.toUri()) } + + prefs.previousSafTrees.mapTo(trees) { it.toUri() } + + return trees.distinct() + } + + private fun StoragePrefs.withPreviousSafTrees(trees: List): StoragePrefs { + return when { + trees.isEmpty() -> this + else -> copy(previousSafTrees = trees.map { it.toString() }) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun collectMediaStoreItems( + resolver: ContentResolver, + volumeName: String, + dest: ArrayList, + ) { + val volumeUri = MediaStore.Files.getContentUri(volumeName) + val columns = arrayOf(BaseColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME) + val idColumn = 0 + val nameColumn = 1 + + try { + resolver.query(volumeUri, columns, null, null)?.use { + dest.ensureCapacity(it.count) + + while (it.moveToNext()) { + val name = it.getString(nameColumn) + val uri = ContentUris.withAppendedId(volumeUri, it.getLong(idColumn)) + + CapturedItems.parseCapturedItem(name, uri)?.let { item -> + dest.add(item) + } + } + } + } catch (e: Exception) { + Log.d(CapturedItems.TAG, "unable to collect MediaStore items, volume $volumeName", e) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun collectSafItems( + resolver: ContentResolver, + treeUri: Uri, + dest: ArrayList, + ) { + val treeId = DocumentsContract.getTreeDocumentId(treeUri) + val childDocumentsUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, treeId) + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + ) + val idColumn = 0 + val nameColumn = 1 + + try { + resolver.query(childDocumentsUri, columns, null, null)?.use { + dest.ensureCapacity(it.count) + + while (it.moveToNext()) { + val name = it.getString(nameColumn) + val id = it.getString(idColumn) + val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id) + + CapturedItems.parseCapturedItem(name, uri)?.let { item -> + dest.add(item) + } + } + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) { + Log.d(CapturedItems.TAG, "unable to collect SAF items, treeUri $treeUri", e) + } + } + } + + /** Unbounded, unlike the tracked list: a tree left out here loses its grant for good. */ + private suspend fun legacyTrees( + joinedUris: String, + onLastCapturedItem: (CapturedItem) -> Unit, + ): List { + val currentTreeUri = storageLocation + .first() + .takeIf { it != CapturedItemRepository.MEDIA_STORE_LOCATION } + ?.toUri() + + val trees = ArrayList() + var checkedLastCapturedItem = false + + joinedUris.split(LEGACY_MEDIA_URI_SEPARATOR).forEach { uriString -> + val uri = uriString.toUri() + val authority = uri.authority ?: return@forEach + + if (!checkedLastCapturedItem) { + reportLastCapturedItem(uri, authority, onLastCapturedItem) + checkedLastCapturedItem = true + } + + if (authority == MediaStore.AUTHORITY) { + return@forEach + } + + val treeUri = DocumentsContract.buildTreeDocumentUri( + authority, + DocumentsContract.getTreeDocumentId(uri), + ) + + val skip = treeUri == currentTreeUri || + trees.contains(treeUri) || + treeUri.toString().contains(CapturedItems.SAF_TREE_SEPARATOR) + + if (skip) { + return@forEach + } + + trees.add(treeUri) + } + + return trees + } + + private fun reportLastCapturedItem( + uri: Uri, + authority: String, + onLastCapturedItem: (CapturedItem) -> Unit, + ) { + val columnName = when (authority) { + MediaStore.AUTHORITY -> MediaStore.MediaColumns.DISPLAY_NAME + else -> DocumentsContract.Document.COLUMN_DISPLAY_NAME + } + + var fileName: String? = null + + try { + context.contentResolver.query(uri, arrayOf(columnName), null, null)?.use { + if (it.moveToFirst()) { + fileName = it.getString(0) + } + } + } catch (_: Exception) { + } + + fileName?.let { name -> + CapturedItems.parseCapturedItem(name, uri)?.let(onLastCapturedItem) + } + } + + companion object { + private const val LEGACY_MEDIA_URI_SEPARATOR = ";" + } +} + +internal class LockscreenCapturedItemRepository( + private val delegate: CapturedItemRepository, +) : CapturedItemRepository by delegate { + + @Suppress("EmptyFunctionBlock") + override suspend fun releaseUntrackedSafTrees() { + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefs.kt b/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefs.kt new file mode 100644 index 00000000..096eb530 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefs.kt @@ -0,0 +1,26 @@ +package app.grapheneos.camera.data.media.store + +import androidx.datastore.core.Serializer +import app.grapheneos.camera.data.core.store.JsonPreferenceSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class MediaPrefs( + @SerialName("last_captured_item") val lastCapturedItem: StoredCapturedItem? = null, +) + +@Serializable +internal data class StoredCapturedItem( + @SerialName("type") + val type: Int, + @SerialName("date_string") + val dateString: String, + @SerialName("uri") + val uri: String, +) + +internal val mediaPrefsSerializer: Serializer = JsonPreferenceSerializer( + serializer = MediaPrefs.serializer(), + defaultValue = MediaPrefs(), +) diff --git a/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefsMigration.kt b/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefsMigration.kt new file mode 100644 index 00000000..0b55c65f --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefsMigration.kt @@ -0,0 +1,66 @@ +package app.grapheneos.camera.data.media.store + +import android.content.Context +import android.content.SharedPreferences +import androidx.datastore.core.DataMigration +import app.grapheneos.camera.data.core.store.LEGACY_CAPTURE_KEYS +import app.grapheneos.camera.data.core.store.LEGACY_LAST_CAPTURED_ITEM_DATE_STRING +import app.grapheneos.camera.data.core.store.LEGACY_LAST_CAPTURED_ITEM_TYPE +import app.grapheneos.camera.data.core.store.LEGACY_LAST_CAPTURED_ITEM_URI +import app.grapheneos.camera.data.core.store.legacyCommonPreferences +import app.grapheneos.camera.data.core.store.removeLegacyCommonKeys + +// The stores migrate the shared file in arbitrary order, so each removes only its own keys. +internal class MediaPrefsMigration( + private val context: Context, +) : DataMigration { + + override suspend fun shouldMigrate(currentData: MediaPrefs): Boolean { + return legacyMediaPreferences().all.keys.any(::owns) || + legacyCommonPreferences(context).all.keys.any(::owns) + } + + override suspend fun migrate(currentData: MediaPrefs): MediaPrefs { + val stored = currentData.lastCapturedItem + ?: readLastCapturedItem(legacyMediaPreferences()) + ?: readLastCapturedItem(legacyCommonPreferences(context)) + + return when { + stored == null -> currentData + else -> currentData.copy(lastCapturedItem = stored) + } + } + + override suspend fun cleanUp() { + context.deleteSharedPreferences(LEGACY_MEDIA_PREFS_NAME) + removeLegacyCommonKeys(context, ::owns) + } + + private fun owns(key: String): Boolean { + return key in LEGACY_CAPTURE_KEYS + } + + private fun readLastCapturedItem(commons: SharedPreferences): StoredCapturedItem? { + val dateString = commons.getString(LEGACY_LAST_CAPTURED_ITEM_DATE_STRING, null) + val uri = commons.getString(LEGACY_LAST_CAPTURED_ITEM_URI, null) + + return when { + dateString == null || uri == null -> null + else -> { + StoredCapturedItem( + type = commons.getInt(LEGACY_LAST_CAPTURED_ITEM_TYPE, -1), + dateString = dateString, + uri = uri, + ) + } + } + } + + private fun legacyMediaPreferences(): SharedPreferences { + return context.getSharedPreferences(LEGACY_MEDIA_PREFS_NAME, Context.MODE_PRIVATE) + } + + private companion object { + private const val LEGACY_MEDIA_PREFS_NAME = "media" + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefs.kt b/app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefs.kt new file mode 100644 index 00000000..3cb9c19d --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefs.kt @@ -0,0 +1,22 @@ +package app.grapheneos.camera.data.media.store + +import androidx.datastore.core.Serializer +import app.grapheneos.camera.data.core.store.JsonPreferenceSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// The current tree and displaced grants share a file so changing location updates both atomically. +@Serializable +internal data class StoragePrefs( + @SerialName("storage_location") + val storageLocation: String? = null, + @SerialName("previous_saf_trees") + val previousSafTrees: List = emptyList(), + @SerialName("legacy_media_uris") + val legacyMediaUris: String? = null, +) + +internal val storagePrefsSerializer: Serializer = JsonPreferenceSerializer( + serializer = StoragePrefs.serializer(), + defaultValue = StoragePrefs(), +) diff --git a/app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefsMigration.kt b/app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefsMigration.kt new file mode 100644 index 00000000..3705207a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefsMigration.kt @@ -0,0 +1,59 @@ +package app.grapheneos.camera.data.media.store + +import android.content.Context +import android.content.SharedPreferences +import androidx.datastore.core.DataMigration +import app.grapheneos.camera.data.core.store.LEGACY_MEDIA_URIS +import app.grapheneos.camera.data.core.store.LEGACY_PREVIOUS_SAF_TREES +import app.grapheneos.camera.data.core.store.LEGACY_STORAGE_KEYS +import app.grapheneos.camera.data.core.store.LEGACY_STORAGE_LOCATION +import app.grapheneos.camera.data.core.store.legacyCommonPreferences +import app.grapheneos.camera.data.core.store.removeLegacyCommonKeys + +// The stores migrate the shared file in arbitrary order, so each removes only its own keys. +internal class StoragePrefsMigration( + private val context: Context, +) : DataMigration { + + override suspend fun shouldMigrate(currentData: StoragePrefs): Boolean { + return legacyCommonPreferences(context).all.keys.any(::owns) + } + + override suspend fun migrate(currentData: StoragePrefs): StoragePrefs { + val commons = legacyCommonPreferences(context) + + return currentData.copy( + storageLocation = stringOrNull(commons, LEGACY_STORAGE_LOCATION) + ?: currentData.storageLocation, + previousSafTrees = readPreviousSafTrees(commons) ?: currentData.previousSafTrees, + legacyMediaUris = stringOrNull(commons, LEGACY_MEDIA_URIS) + ?: currentData.legacyMediaUris, + ) + } + + override suspend fun cleanUp() { + removeLegacyCommonKeys(context, ::owns) + } + + private fun owns(key: String): Boolean { + return key in LEGACY_STORAGE_KEYS + } + + private fun readPreviousSafTrees(commons: SharedPreferences): List? { + val stored = stringOrNull(commons, LEGACY_PREVIOUS_SAF_TREES) ?: return null + + return stored.split(SAF_TREE_SEPARATOR) + } + + private fun stringOrNull(preferences: SharedPreferences, key: String): String? { + return when { + preferences.contains(key) -> preferences.getString(key, null) + else -> null + } + } + + private companion object { + // This separator belongs to the shipped legacy format. + private const val SAF_TREE_SEPARATOR = "\u0000" + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/mapper/CameraSettingsMapper.kt b/app/src/main/java/app/grapheneos/camera/data/settings/mapper/CameraSettingsMapper.kt new file mode 100644 index 00000000..f44e1576 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/mapper/CameraSettingsMapper.kt @@ -0,0 +1,108 @@ +package app.grapheneos.camera.data.settings.mapper + +import app.grapheneos.camera.data.settings.model.CameraSettings +import app.grapheneos.camera.data.settings.model.GridType +import app.grapheneos.camera.data.settings.model.SettingsDefaults +import app.grapheneos.camera.data.settings.store.StoredCameraSettings +import app.grapheneos.camera.data.settings.store.StoredGridType +import javax.inject.Inject + +internal interface CameraSettingsMapper { + + fun map(stored: StoredCameraSettings): CameraSettings + + fun map(settings: CameraSettings): StoredCameraSettings +} + +internal class CameraSettingsMapperImpl @Inject constructor() : CameraSettingsMapper { + + @Suppress("CyclomaticComplexMethod") + override fun map(stored: StoredCameraSettings): CameraSettings { + return CameraSettings( + aspectRatio = stored.aspectRatio ?: SettingsDefaults.ASPECT_RATIO, + gridType = map(stored.gridType), + focusTimeoutSeconds = stored.focusTimeoutSeconds + ?: SettingsDefaults.FOCUS_TIMEOUT_SECONDS, + selfTimerDurationSeconds = stored.selfTimerDurationSeconds + ?: SettingsDefaults.SELF_TIMER_DURATION, + enableCameraSounds = stored.enableCameraSounds ?: SettingsDefaults.CAMERA_SOUNDS, + includeAudio = stored.includeAudio ?: SettingsDefaults.INCLUDE_AUDIO, + enableEis = stored.enableEis ?: SettingsDefaults.ENABLE_EIS, + enableZsl = stored.enableZsl ?: SettingsDefaults.ENABLE_ZSL, + waitForFocusLock = stored.waitForFocusLock ?: SettingsDefaults.WAIT_FOR_FOCUS_LOCK, + selectHighestResolution = stored.selectHighestResolution + ?: SettingsDefaults.SELECT_HIGHEST_RESOLUTION, + photoQuality = stored.photoQuality ?: SettingsDefaults.PHOTO_QUALITY, + removeExifAfterCapture = stored.removeExifAfterCapture + ?: SettingsDefaults.REMOVE_EXIF_AFTER_CAPTURE, + gyroscopeSuggestions = stored.gyroscopeSuggestions + ?: SettingsDefaults.GYROSCOPE_SUGGESTIONS, + saveImageAsPreviewed = stored.saveImageAsPreviewed + ?: SettingsDefaults.SAVE_IMAGE_AS_PREVIEW, + saveVideoAsPreviewed = stored.saveVideoAsPreviewed + ?: SettingsDefaults.SAVE_VIDEO_AS_PREVIEW, + scanAllCodes = stored.scanAllCodes ?: SettingsDefaults.SCAN_ALL_CODES, + enabledBarcodeFormats = stored.enabledBarcodeFormats + ?: SettingsDefaults.ENABLED_BARCODE_FORMATS, + ) + } + + @Suppress("SimplifyBooleanWithConstants") + override fun map(settings: CameraSettings): StoredCameraSettings { + return StoredCameraSettings( + aspectRatio = settings.aspectRatio.takeUnless { it == SettingsDefaults.ASPECT_RATIO }, + gridType = settings.gridType + .takeUnless { it == SettingsDefaults.GRID_TYPE } + ?.let(::map), + focusTimeoutSeconds = settings.focusTimeoutSeconds + .takeUnless { it == SettingsDefaults.FOCUS_TIMEOUT_SECONDS }, + selfTimerDurationSeconds = settings.selfTimerDurationSeconds + .takeUnless { it == SettingsDefaults.SELF_TIMER_DURATION }, + enableCameraSounds = settings.enableCameraSounds + .takeUnless { it == SettingsDefaults.CAMERA_SOUNDS }, + includeAudio = settings.includeAudio + .takeUnless { it == SettingsDefaults.INCLUDE_AUDIO }, + enableEis = settings.enableEis.takeUnless { it == SettingsDefaults.ENABLE_EIS }, + enableZsl = settings.enableZsl.takeUnless { it == SettingsDefaults.ENABLE_ZSL }, + waitForFocusLock = settings.waitForFocusLock + .takeUnless { it == SettingsDefaults.WAIT_FOR_FOCUS_LOCK }, + selectHighestResolution = settings.selectHighestResolution + .takeUnless { it == SettingsDefaults.SELECT_HIGHEST_RESOLUTION }, + photoQuality = settings.photoQuality + .takeUnless { it == SettingsDefaults.PHOTO_QUALITY }, + removeExifAfterCapture = settings.removeExifAfterCapture + .takeUnless { it == SettingsDefaults.REMOVE_EXIF_AFTER_CAPTURE }, + gyroscopeSuggestions = settings.gyroscopeSuggestions + .takeUnless { it == SettingsDefaults.GYROSCOPE_SUGGESTIONS }, + saveImageAsPreviewed = settings.saveImageAsPreviewed + .takeUnless { it == SettingsDefaults.SAVE_IMAGE_AS_PREVIEW }, + saveVideoAsPreviewed = settings.saveVideoAsPreviewed + .takeUnless { it == SettingsDefaults.SAVE_VIDEO_AS_PREVIEW }, + scanAllCodes = settings.scanAllCodes + .takeUnless { it == SettingsDefaults.SCAN_ALL_CODES }, + enabledBarcodeFormats = settings.enabledBarcodeFormats + .takeUnless { it == SettingsDefaults.ENABLED_BARCODE_FORMATS }, + ) + } + + private fun map(stored: StoredGridType?): GridType { + return when (stored) { + null, + StoredGridType.UNKNOWN, + -> SettingsDefaults.GRID_TYPE + StoredGridType.NONE -> GridType.NONE + StoredGridType.THREE_BY_THREE -> GridType.THREE_BY_THREE + StoredGridType.FOUR_BY_FOUR -> GridType.FOUR_BY_FOUR + StoredGridType.GOLDEN_RATIO -> GridType.GOLDEN_RATIO + } + } + + private fun map(type: GridType): StoredGridType { + return when (type) { + GridType.NONE -> StoredGridType.NONE + GridType.THREE_BY_THREE -> StoredGridType.THREE_BY_THREE + GridType.FOUR_BY_FOUR -> StoredGridType.FOUR_BY_FOUR + GridType.GOLDEN_RATIO -> StoredGridType.GOLDEN_RATIO + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/mapper/ModeSettingsMapper.kt b/app/src/main/java/app/grapheneos/camera/data/settings/mapper/ModeSettingsMapper.kt new file mode 100644 index 00000000..1cdf9604 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/mapper/ModeSettingsMapper.kt @@ -0,0 +1,40 @@ +package app.grapheneos.camera.data.settings.mapper + +import androidx.camera.video.Quality +import app.grapheneos.camera.data.settings.model.ModeSettings +import app.grapheneos.camera.data.settings.model.SettingsDefaults +import app.grapheneos.camera.data.settings.store.StoredModeSettings +import app.grapheneos.camera.data.settings.store.StoredVideoQuality +import javax.inject.Inject + +internal interface ModeSettingsMapper { + + fun map(stored: StoredModeSettings, isFrontFacing: Boolean): ModeSettings +} + +internal class ModeSettingsMapperImpl @Inject constructor() : ModeSettingsMapper { + + override fun map(stored: StoredModeSettings, isFrontFacing: Boolean): ModeSettings { + val storedQuality = when { + isFrontFacing -> stored.videoQualityFront + else -> stored.videoQualityBack + } + + return ModeSettings( + flashMode = stored.flashMode ?: SettingsDefaults.FLASH_MODE, + geoTagging = stored.geoTagging ?: SettingsDefaults.GEO_TAGGING, + selfIllumination = stored.selfIllumination ?: SettingsDefaults.SELF_ILLUMINATION, + videoQuality = mapVideoQuality(storedQuality), + ) + } + + private fun mapVideoQuality(stored: StoredVideoQuality): Quality { + return when (stored) { + StoredVideoQuality.DEVICE_CHOICE -> SettingsDefaults.VIDEO_QUALITY + StoredVideoQuality.UHD -> Quality.UHD + StoredVideoQuality.FHD -> Quality.FHD + StoredVideoQuality.HD -> Quality.HD + StoredVideoQuality.SD -> Quality.SD + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/mapper/StoredVideoQualityMapper.kt b/app/src/main/java/app/grapheneos/camera/data/settings/mapper/StoredVideoQualityMapper.kt new file mode 100644 index 00000000..af194431 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/mapper/StoredVideoQualityMapper.kt @@ -0,0 +1,23 @@ +package app.grapheneos.camera.data.settings.mapper + +import androidx.camera.video.Quality +import app.grapheneos.camera.data.settings.store.StoredVideoQuality +import javax.inject.Inject + +internal interface StoredVideoQualityMapper { + + fun map(quality: Quality): StoredVideoQuality +} + +internal class StoredVideoQualityMapperImpl @Inject constructor() : StoredVideoQualityMapper { + + override fun map(quality: Quality): StoredVideoQuality { + return when (quality) { + Quality.UHD -> StoredVideoQuality.UHD + Quality.FHD -> StoredVideoQuality.FHD + Quality.HD -> StoredVideoQuality.HD + Quality.SD -> StoredVideoQuality.SD + else -> StoredVideoQuality.DEVICE_CHOICE + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/model/CameraSettings.kt b/app/src/main/java/app/grapheneos/camera/data/settings/model/CameraSettings.kt new file mode 100644 index 00000000..d4681d17 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/CameraSettings.kt @@ -0,0 +1,41 @@ +package app.grapheneos.camera.data.settings.model + +data class CameraSettings( + val aspectRatio: Int = SettingsDefaults.ASPECT_RATIO, + val gridType: GridType = SettingsDefaults.GRID_TYPE, + val focusTimeoutSeconds: Long = SettingsDefaults.FOCUS_TIMEOUT_SECONDS, + val selfTimerDurationSeconds: Int = SettingsDefaults.SELF_TIMER_DURATION, + val enableCameraSounds: Boolean = SettingsDefaults.CAMERA_SOUNDS, + val includeAudio: Boolean = SettingsDefaults.INCLUDE_AUDIO, + val enableEis: Boolean = SettingsDefaults.ENABLE_EIS, + val enableZsl: Boolean = SettingsDefaults.ENABLE_ZSL, + val waitForFocusLock: Boolean = SettingsDefaults.WAIT_FOR_FOCUS_LOCK, + val selectHighestResolution: Boolean = SettingsDefaults.SELECT_HIGHEST_RESOLUTION, + val photoQuality: Int = SettingsDefaults.PHOTO_QUALITY, + val removeExifAfterCapture: Boolean = SettingsDefaults.REMOVE_EXIF_AFTER_CAPTURE, + val gyroscopeSuggestions: Boolean = SettingsDefaults.GYROSCOPE_SUGGESTIONS, + val saveImageAsPreviewed: Boolean = SettingsDefaults.SAVE_IMAGE_AS_PREVIEW, + val saveVideoAsPreviewed: Boolean = SettingsDefaults.SAVE_VIDEO_AS_PREVIEW, + val scanAllCodes: Boolean = SettingsDefaults.SCAN_ALL_CODES, + val enabledBarcodeFormats: Set = SettingsDefaults.ENABLED_BARCODE_FORMATS, +) { + fun withBarcodeFormat(formatName: String, enabled: Boolean): CameraSettings { + val formats = when { + enabled -> enabledBarcodeFormats + formatName + else -> enabledBarcodeFormats - formatName + } + + return copy(enabledBarcodeFormats = formats) + } + + companion object { + const val FOCUS_TIMEOUT_OFF = "Off" + } +} + +fun focusTimeoutLabel(seconds: Long): String { + return when (seconds) { + 0L -> CameraSettings.FOCUS_TIMEOUT_OFF + else -> "${seconds}s" + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/model/GridType.kt b/app/src/main/java/app/grapheneos/camera/data/settings/model/GridType.kt new file mode 100644 index 00000000..c7f60e79 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/GridType.kt @@ -0,0 +1,8 @@ +package app.grapheneos.camera.data.settings.model + +enum class GridType { + NONE, + THREE_BY_THREE, + FOUR_BY_FOUR, + GOLDEN_RATIO, +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/model/ModeSettings.kt b/app/src/main/java/app/grapheneos/camera/data/settings/model/ModeSettings.kt new file mode 100644 index 00000000..f337abc3 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/ModeSettings.kt @@ -0,0 +1,10 @@ +package app.grapheneos.camera.data.settings.model + +import androidx.camera.video.Quality + +data class ModeSettings( + val flashMode: Int = SettingsDefaults.FLASH_MODE, + val geoTagging: Boolean = SettingsDefaults.GEO_TAGGING, + val selfIllumination: Boolean = SettingsDefaults.SELF_ILLUMINATION, + val videoQuality: Quality = SettingsDefaults.VIDEO_QUALITY, +) diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/model/SettingsDefaults.kt b/app/src/main/java/app/grapheneos/camera/data/settings/model/SettingsDefaults.kt new file mode 100644 index 00000000..3d7a3334 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/SettingsDefaults.kt @@ -0,0 +1,51 @@ +package app.grapheneos.camera.data.settings.model + +import androidx.camera.core.AspectRatio +import androidx.camera.core.ImageCapture +import androidx.camera.video.Quality +import com.google.zxing.BarcodeFormat + +object SettingsDefaults { + + val GRID_TYPE = GridType.NONE + + val VIDEO_QUALITY: Quality = Quality.HIGHEST + + val ENABLED_BARCODE_FORMATS = setOf(BarcodeFormat.QR_CODE.name) + + const val ASPECT_RATIO = AspectRatio.RATIO_4_3 + + const val FLASH_MODE = ImageCapture.FLASH_MODE_OFF + + const val FOCUS_TIMEOUT_SECONDS = 5L + + const val SELF_ILLUMINATION = false + + const val GEO_TAGGING = false + + const val INCLUDE_AUDIO = true + + const val ENABLE_EIS = true + + const val SCAN_ALL_CODES = false + + const val SAVE_IMAGE_AS_PREVIEW = true + + const val SAVE_VIDEO_AS_PREVIEW = true + + const val PHOTO_QUALITY = 95 + + const val REMOVE_EXIF_AFTER_CAPTURE = true + + const val GYROSCOPE_SUGGESTIONS = false + + const val CAMERA_SOUNDS = true + + const val ENABLE_ZSL = false + + const val SELECT_HIGHEST_RESOLUTION = false + + const val WAIT_FOR_FOCUS_LOCK = false + + const val SELF_TIMER_DURATION = 0 +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/model/VideoQualityTitle.kt b/app/src/main/java/app/grapheneos/camera/data/settings/model/VideoQualityTitle.kt new file mode 100644 index 00000000..ed756b7f --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/VideoQualityTitle.kt @@ -0,0 +1,31 @@ +package app.grapheneos.camera.data.settings.model + +import androidx.camera.video.Quality + +// TODO: move these into strings.xml, which they could not be while they were also the storage +// format. They wait for the settings screen rewrite that owns this setting. +private const val TITLE_UHD = "2160p (UHD)" +private const val TITLE_FHD = "1080p (FHD)" +private const val TITLE_HD = "720p (HD)" +private const val TITLE_SD = "480p (SD)" +private const val TITLE_UNKNOWN = "Unknown" + +fun videoQualityTitle(quality: Quality): String { + return when (quality) { + Quality.UHD -> TITLE_UHD + Quality.FHD -> TITLE_FHD + Quality.HD -> TITLE_HD + Quality.SD -> TITLE_SD + else -> TITLE_UNKNOWN + } +} + +fun videoQualityFromTitle(title: String): Quality { + return when (title) { + TITLE_UHD -> Quality.UHD + TITLE_FHD -> Quality.FHD + TITLE_HD -> Quality.HD + TITLE_SD -> Quality.SD + else -> Quality.SD + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsRepository.kt b/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsRepository.kt new file mode 100644 index 00000000..0a193ef4 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsRepository.kt @@ -0,0 +1,120 @@ +package app.grapheneos.camera.data.settings.repository + +import androidx.camera.video.Quality +import androidx.datastore.core.DataStore +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapper +import app.grapheneos.camera.data.settings.mapper.ModeSettingsMapper +import app.grapheneos.camera.data.settings.mapper.StoredVideoQualityMapper +import app.grapheneos.camera.data.settings.model.CameraSettings +import app.grapheneos.camera.data.settings.model.ModeSettings +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.data.settings.store.StoredModeSettings +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +interface SettingsRepository { + + val settings: Flow + + suspend fun update(transform: (CameraSettings) -> CameraSettings): CameraSettings + + suspend fun selectMode(mode: CameraMode, isFrontFacing: Boolean): ModeSettings + + suspend fun setFlashMode(value: Int): ModeSettings? + + suspend fun setGeoTagging(value: Boolean): ModeSettings? + + suspend fun setSelfIllumination(value: Boolean): ModeSettings? + + suspend fun setVideoQuality(value: Quality): ModeSettings? +} + +internal class SettingsRepositoryImpl @Inject constructor( + private val dataStore: DataStore, + private val cameraSettingsMapper: CameraSettingsMapper, + private val modeSettingsMapper: ModeSettingsMapper, + private val storedVideoQualityMapper: StoredVideoQualityMapper, +) : SettingsRepository { + + override val settings: Flow = dataStore.data.map { + cameraSettingsMapper.map(it.common) + } + + private var slotted: SlottedMode? = null + + override suspend fun update(transform: (CameraSettings) -> CameraSettings): CameraSettings { + return dataStore + .updateData { prefs -> + val settings = transform(cameraSettingsMapper.map(prefs.common)) + + prefs.copy(common = cameraSettingsMapper.map(settings)) + } + .let { cameraSettingsMapper.map(it.common) } + } + + override suspend fun selectMode(mode: CameraMode, isFrontFacing: Boolean): ModeSettings { + slotted = SlottedMode(mode = mode, isFrontFacing = isFrontFacing) + + return modeSettingsMapper.map( + stored = dataStore.data.first().mode(mode), + isFrontFacing = isFrontFacing, + ) + } + + override suspend fun setFlashMode(value: Int): ModeSettings? { + return writeMode({ it.copy(flashMode = value) }) { stored, _ -> + stored.copy(flashMode = value) + } + } + + override suspend fun setGeoTagging(value: Boolean): ModeSettings? { + return writeMode({ it.copy(geoTagging = value) }) { stored, _ -> + stored.copy(geoTagging = value) + } + } + + override suspend fun setSelfIllumination(value: Boolean): ModeSettings? { + return writeMode({ it.copy(selfIllumination = value) }) { stored, _ -> + stored.copy(selfIllumination = value) + } + } + + override suspend fun setVideoQuality(value: Quality): ModeSettings? { + return writeMode({ it.copy(videoQuality = value) }) { stored, mode -> + val quality = storedVideoQualityMapper.map(value) + + when { + mode.isFrontFacing -> stored.copy(videoQualityFront = quality) + else -> stored.copy(videoQualityBack = quality) + } + } + } + + private suspend fun writeMode( + update: (ModeSettings) -> ModeSettings, + store: (StoredModeSettings, SlottedMode) -> StoredModeSettings, + ): ModeSettings? { + val mode = slotted ?: return null + + val prefs = dataStore.updateData { prefs -> + val stored = store(prefs.mode(mode.mode), mode) + + prefs.withMode(mode = mode.mode, settings = stored) + } + + return update( + modeSettingsMapper.map( + stored = prefs.mode(mode.mode), + isFrontFacing = mode.isFrontFacing, + ), + ) + } + + private data class SlottedMode( + val mode: CameraMode, + val isFrontFacing: Boolean, + ) +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefs.kt b/app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefs.kt new file mode 100644 index 00000000..bd528367 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefs.kt @@ -0,0 +1,177 @@ +package app.grapheneos.camera.data.settings.store + +import androidx.datastore.core.Serializer +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.core.store.JsonPreferenceSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable +internal data class SettingsPrefs( + val common: StoredCameraSettings = StoredCameraSettings(), + val modes: Map = emptyMap(), +) { + fun mode(mode: CameraMode): StoredModeSettings { + return modes[storedModeName(mode)] ?: StoredModeSettings() + } + + fun withMode(mode: CameraMode, settings: StoredModeSettings): SettingsPrefs { + return copy(modes = modes + (storedModeName(mode) to settings)) + } + + companion object { + fun storedModeName(mode: CameraMode): String { + return when (mode) { + CameraMode.QR_SCAN -> "QR_SCAN" + CameraMode.AUTO -> "AUTO" + CameraMode.FACE_RETOUCH -> "FACE_RETOUCH" + CameraMode.PORTRAIT -> "PORTRAIT" + CameraMode.NIGHT -> "NIGHT" + CameraMode.HDR -> "HDR" + CameraMode.CAMERA -> "CAMERA" + CameraMode.VIDEO -> "VIDEO" + } + } + } +} + +@Serializable +internal data class StoredCameraSettings( + @SerialName("aspect_ratio") + val aspectRatio: Int? = null, + @SerialName("grid_type") + val gridType: StoredGridType? = null, + @SerialName("focus_timeout_seconds") + val focusTimeoutSeconds: Long? = null, + @SerialName("self_timer_duration_seconds") + val selfTimerDurationSeconds: Int? = null, + @SerialName("enable_camera_sounds") + val enableCameraSounds: Boolean? = null, + @SerialName("include_audio") + val includeAudio: Boolean? = null, + @SerialName("enable_eis") + val enableEis: Boolean? = null, + @SerialName("enable_zsl") + val enableZsl: Boolean? = null, + @SerialName("wait_for_focus_lock") + val waitForFocusLock: Boolean? = null, + @SerialName("select_highest_resolution") + val selectHighestResolution: Boolean? = null, + @SerialName("photo_quality") + val photoQuality: Int? = null, + @SerialName("remove_exif_after_capture") + val removeExifAfterCapture: Boolean? = null, + @SerialName("gyroscope_suggestions") + val gyroscopeSuggestions: Boolean? = null, + @SerialName("save_image_as_previewed") + val saveImageAsPreviewed: Boolean? = null, + @SerialName("save_video_as_previewed") + val saveVideoAsPreviewed: Boolean? = null, + @SerialName("scan_all_codes") + val scanAllCodes: Boolean? = null, + @SerialName("enabled_barcode_formats") + val enabledBarcodeFormats: Set? = null, +) + +@Serializable +internal data class StoredModeSettings( + @SerialName("flash_mode") + val flashMode: Int? = null, + @SerialName("geo_tagging") + val geoTagging: Boolean? = null, + @SerialName("self_illumination") + val selfIllumination: Boolean? = null, + @SerialName("video_quality_front") + val videoQualityFront: StoredVideoQuality = StoredVideoQuality.DEVICE_CHOICE, + @SerialName("video_quality_back") + val videoQualityBack: StoredVideoQuality = StoredVideoQuality.DEVICE_CHOICE, +) + +@Serializable(with = StoredGridTypeSerializer::class) +internal enum class StoredGridType { + UNKNOWN, + NONE, + THREE_BY_THREE, + FOUR_BY_FOUR, + GOLDEN_RATIO, +} + +internal object StoredGridTypeSerializer : KSerializer { + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + serialName = "StoredGridType", + kind = PrimitiveKind.STRING, + ) + + override fun serialize(encoder: Encoder, value: StoredGridType) { + encoder.encodeString(wireName(value)) + } + + override fun deserialize(decoder: Decoder): StoredGridType { + val stored = decoder.decodeString() + + return StoredGridType.entries.firstOrNull { wireName(it) == stored } + ?: StoredGridType.UNKNOWN + } + + private fun wireName(type: StoredGridType): String { + return when (type) { + StoredGridType.UNKNOWN -> "UNKNOWN" + StoredGridType.NONE -> "NONE" + StoredGridType.THREE_BY_THREE -> "THREE_BY_THREE" + StoredGridType.FOUR_BY_FOUR -> "FOUR_BY_FOUR" + StoredGridType.GOLDEN_RATIO -> "GOLDEN_RATIO" + } + } +} + +@Serializable(with = StoredVideoQualitySerializer::class) +internal enum class StoredVideoQuality { + DEVICE_CHOICE, + UHD, + FHD, + HD, + SD, +} + +// Unknown enum names must not make DataStore replace the entire settings file as corrupt. +internal object StoredVideoQualitySerializer : KSerializer { + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + serialName = "StoredVideoQuality", + kind = PrimitiveKind.STRING, + ) + + override fun serialize(encoder: Encoder, value: StoredVideoQuality) { + encoder.encodeString(wireName(value)) + } + + override fun deserialize(decoder: Decoder): StoredVideoQuality { + val stored = decoder.decodeString() + + return StoredVideoQuality.entries.firstOrNull { wireName(it) == stored } + ?: StoredVideoQuality.DEVICE_CHOICE + } + + // Enum constant names are not the persisted wire format. + private fun wireName(quality: StoredVideoQuality): String { + return when (quality) { + StoredVideoQuality.DEVICE_CHOICE -> "DEVICE_CHOICE" + StoredVideoQuality.UHD -> "UHD" + StoredVideoQuality.FHD -> "FHD" + StoredVideoQuality.HD -> "HD" + StoredVideoQuality.SD -> "SD" + } + } +} + +internal val settingsPrefsSerializer: Serializer = JsonPreferenceSerializer( + serializer = SettingsPrefs.serializer(), + defaultValue = SettingsPrefs(), +) diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefsMigration.kt b/app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefsMigration.kt new file mode 100644 index 00000000..a0965e45 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefsMigration.kt @@ -0,0 +1,258 @@ +package app.grapheneos.camera.data.settings.store + +import android.content.Context +import android.content.SharedPreferences +import androidx.datastore.core.DataMigration +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.core.store.LEGACY_CAPTURE_KEYS +import app.grapheneos.camera.data.core.store.LEGACY_STORAGE_KEYS +import app.grapheneos.camera.data.core.store.legacyCommonPreferences +import app.grapheneos.camera.data.core.store.removeLegacyCommonKeys +import app.grapheneos.camera.data.settings.model.SettingsDefaults + +// Settings owns every shared-file key not claimed by capture or storage. The constants and +// fallbacks below describe shipped formats and must remain independent of current UI names. +@Suppress("TooManyFunctions") +internal class SettingsPrefsMigration( + private val context: Context, +) : DataMigration { + + override suspend fun shouldMigrate(currentData: SettingsPrefs): Boolean { + val ownsSomething = legacyCommonPreferences(context).all.keys.any(::owns) + + return ownsSomething || CameraMode.entries.any { + preferences(SettingsPrefs.storedModeName(it)).all.isNotEmpty() + } + } + + override suspend fun migrate(currentData: SettingsPrefs): SettingsPrefs { + return currentData.copy( + common = readCommonSettings( + commons = legacyCommonPreferences(context), + current = currentData.common, + ), + modes = readModeSettings(currentData), + ) + } + + override suspend fun cleanUp() { + CameraMode.entries.forEach { mode -> + context.deleteSharedPreferences(SettingsPrefs.storedModeName(mode)) + } + + removeLegacyCommonKeys(context, ::owns) + } + + private fun owns(key: String): Boolean { + return key !in LEGACY_CAPTURE_KEYS && key !in LEGACY_STORAGE_KEYS + } + + @Suppress("CyclomaticComplexMethod") + private fun readCommonSettings( + commons: SharedPreferences, + current: StoredCameraSettings, + ): StoredCameraSettings { + return current.copy( + aspectRatio = intOrNull(commons, ASPECT_RATIO) ?: current.aspectRatio, + gridType = readGridType(commons) ?: current.gridType, + focusTimeoutSeconds = readFocusTimeoutSeconds(commons) + ?: current.focusTimeoutSeconds, + selfTimerDurationSeconds = intOrNull(commons, SELF_TIMER_DURATION) + ?: current.selfTimerDurationSeconds, + enableCameraSounds = booleanOrNull(commons, CAMERA_SOUNDS) + ?: current.enableCameraSounds, + includeAudio = booleanOrNull(commons, INCLUDE_AUDIO) ?: current.includeAudio, + enableEis = booleanOrNull(commons, ENABLE_EIS) ?: current.enableEis, + enableZsl = booleanOrNull(commons, ENABLE_ZSL) ?: current.enableZsl, + waitForFocusLock = booleanOrNull(commons, WAIT_FOR_FOCUS_LOCK) + ?: current.waitForFocusLock, + selectHighestResolution = booleanOrNull(commons, SELECT_HIGHEST_RESOLUTION) + ?: current.selectHighestResolution, + photoQuality = readPhotoQuality(commons, current.photoQuality), + removeExifAfterCapture = booleanOrNull(commons, REMOVE_EXIF_AFTER_CAPTURE) + ?: current.removeExifAfterCapture, + gyroscopeSuggestions = booleanOrNull(commons, GYROSCOPE_SUGGESTIONS) + ?: current.gyroscopeSuggestions, + saveImageAsPreviewed = booleanOrNull(commons, SAVE_IMAGE_AS_PREVIEW) + ?: current.saveImageAsPreviewed, + saveVideoAsPreviewed = readSaveVideoAsPreviewed( + commons = commons, + current = current.saveVideoAsPreviewed, + ), + scanAllCodes = booleanOrNull(commons, SCAN_ALL_CODES) ?: current.scanAllCodes, + enabledBarcodeFormats = readEnabledBarcodeFormats(commons) + ?: current.enabledBarcodeFormats, + ) + } + + private fun readGridType(commons: SharedPreferences): StoredGridType? { + val ordinal = intOrNull(commons, GRID) ?: return null + + return LEGACY_GRID_TYPES.getOrNull(ordinal) ?: StoredGridType.NONE + } + + private fun readFocusTimeoutSeconds(commons: SharedPreferences): Long? { + return when (val label = commons.getString(FOCUS_TIMEOUT, null)) { + null -> null + FOCUS_TIMEOUT_OFF -> 0L + else -> label.removeSuffix("s").toLongOrNull() + ?: SettingsDefaults.FOCUS_TIMEOUT_SECONDS + } + } + + // Older versions stored either a quality toggle or an invalid zero from the seek bar. + private fun readPhotoQuality(commons: SharedPreferences, current: Int?): Int? { + val stored = intOrNull(commons, PHOTO_QUALITY) + + return when { + stored != null -> stored.takeIf { it > 0 } ?: SettingsDefaults.PHOTO_QUALITY + booleanOrNull(commons, EMPHASIS_ON_QUALITY) == true -> MAX_PHOTO_QUALITY + commons.contains(EMPHASIS_ON_QUALITY) -> SettingsDefaults.PHOTO_QUALITY + else -> current + } + } + + // Installs predating this setting recorded without preview mirroring. + private fun readSaveVideoAsPreviewed( + commons: SharedPreferences, + current: Boolean?, + ): Boolean? { + val predatesTheSetting = commons.contains(SAVE_IMAGE_AS_PREVIEW) && + !commons.contains(SAVE_VIDEO_AS_PREVIEW) + + return when { + predatesTheSetting -> false + else -> booleanOrNull(commons, SAVE_VIDEO_AS_PREVIEW) ?: current + } + } + + // The default format's key marked the legacy set as explicitly configured. + private fun readEnabledBarcodeFormats(commons: SharedPreferences): Set? { + if (!commons.contains(DEFAULT_SCAN_KEY)) { + return null + } + + return commons.all.keys + .filter { it.startsWith(SCAN_PREFIX) && it != SCAN_ALL_CODES } + .filterTo(mutableSetOf()) { commons.getBoolean(it, false) } + .mapTo(mutableSetOf()) { it.removePrefix(SCAN_PREFIX) } + } + + private fun readModeSettings(current: SettingsPrefs): Map { + var migrated = current + + CameraMode.entries.forEach { mode -> + val modePreferences = preferences(SettingsPrefs.storedModeName(mode)) + + if (modePreferences.all.isNotEmpty()) { + migrated = migrated.withMode( + mode = mode, + settings = readMode( + modePreferences = modePreferences, + current = migrated.mode(mode), + ), + ) + } + } + + return migrated.modes + } + + private fun readMode( + modePreferences: SharedPreferences, + current: StoredModeSettings, + ): StoredModeSettings { + return current.copy( + flashMode = intOrNull(modePreferences, FLASH_MODE) ?: current.flashMode, + geoTagging = booleanOrNull(modePreferences, GEO_TAGGING) ?: current.geoTagging, + selfIllumination = booleanOrNull(modePreferences, SELF_ILLUMINATION) + ?: current.selfIllumination, + videoQualityFront = readVideoQuality( + modePreferences = modePreferences, + key = VIDEO_QUALITY_FRONT, + current = current.videoQualityFront, + ), + videoQualityBack = readVideoQuality( + modePreferences = modePreferences, + key = VIDEO_QUALITY_BACK, + current = current.videoQualityBack, + ), + ) + } + + // The shipped placeholder label fell through to SD, so preserve that migration behavior. + private fun readVideoQuality( + modePreferences: SharedPreferences, + key: String, + current: StoredVideoQuality, + ): StoredVideoQuality { + val title = modePreferences.getString(key, null) + ?: return current + + return LEGACY_VIDEO_QUALITIES[title] ?: UNRECOGNISED_VIDEO_QUALITY + } + + private fun intOrNull(preferences: SharedPreferences, key: String): Int? { + return when { + preferences.contains(key) -> preferences.getInt(key, 0) + else -> null + } + } + + private fun booleanOrNull(preferences: SharedPreferences, key: String): Boolean? { + return when { + preferences.contains(key) -> preferences.getBoolean(key, false) + else -> null + } + } + + private fun preferences(name: String): SharedPreferences { + return context.getSharedPreferences(name, Context.MODE_PRIVATE) + } + + private companion object { + private const val ASPECT_RATIO = "aspect_ratio" + private const val CAMERA_SOUNDS = "camera_sounds" + private const val EMPHASIS_ON_QUALITY = "emphasis_on_quality" + private const val ENABLE_EIS = "enable_eis" + private const val ENABLE_ZSL = "enable_zsl" + private const val FLASH_MODE = "flash_mode" + private const val FOCUS_TIMEOUT = "focus_timeout" + private const val GEO_TAGGING = "geo_tagging" + private const val GRID = "grid" + private const val GYROSCOPE_SUGGESTIONS = "gyroscope_suggestions" + private const val INCLUDE_AUDIO = "include_audio" + private const val PHOTO_QUALITY = "photo_quality" + private const val REMOVE_EXIF_AFTER_CAPTURE = "remove_exif_after_capture" + private const val SAVE_IMAGE_AS_PREVIEW = "save_image_as_preview" + private const val SAVE_VIDEO_AS_PREVIEW = "save_video_as_preview" + private const val SCAN_ALL_CODES = "scan_all_codes" + private const val SCAN_PREFIX = "scan_" + private const val DEFAULT_SCAN_KEY = "scan_QR_CODE" + private const val SELECT_HIGHEST_RESOLUTION = "select_highest_resolution" + private const val SELF_ILLUMINATION = "self_illumination" + private const val SELF_TIMER_DURATION = "self_timer_duration" + private const val WAIT_FOR_FOCUS_LOCK = "wait_for_focus_lock" + private const val VIDEO_QUALITY_FRONT = "video_quality_FRONT" + private const val VIDEO_QUALITY_BACK = "video_quality_BACK" + + private const val FOCUS_TIMEOUT_OFF = "Off" + private const val MAX_PHOTO_QUALITY = 100 + private val UNRECOGNISED_VIDEO_QUALITY = StoredVideoQuality.SD + + // This order is the shipped ordinal wire format. + private val LEGACY_GRID_TYPES = listOf( + StoredGridType.NONE, + StoredGridType.THREE_BY_THREE, + StoredGridType.FOUR_BY_FOUR, + StoredGridType.GOLDEN_RATIO, + ) + + private val LEGACY_VIDEO_QUALITIES = mapOf( + "2160p (UHD)" to StoredVideoQuality.UHD, + "1080p (FHD)" to StoredVideoQuality.FHD, + "720p (HD)" to StoredVideoQuality.HD, + "480p (SD)" to StoredVideoQuality.SD, + ) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/di/core/CoroutinesProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/core/CoroutinesProvidesModule.kt new file mode 100644 index 00000000..34765dcf --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/core/CoroutinesProvidesModule.kt @@ -0,0 +1,31 @@ +package app.grapheneos.camera.di.core + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@Module +@InstallIn(SingletonComponent::class) +internal class CoroutinesProvidesModule { + + @Provides + @IoDispatcher + fun provideIoDispatcher(): CoroutineDispatcher { + return Dispatchers.IO + } + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope { + return CoroutineScope(SupervisorJob() + dispatcher) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/di/core/Qualifiers.kt b/app/src/main/java/app/grapheneos/camera/di/core/Qualifiers.kt new file mode 100644 index 00000000..fa53fc46 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/core/Qualifiers.kt @@ -0,0 +1,15 @@ +package app.grapheneos.camera.di.core + +import javax.inject.Qualifier + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class IoDispatcher + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class ApplicationScope + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class DurablePreferences diff --git a/app/src/main/java/app/grapheneos/camera/di/media/MediaProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/media/MediaProvidesModule.kt new file mode 100644 index 00000000..97228114 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/media/MediaProvidesModule.kt @@ -0,0 +1,30 @@ +package app.grapheneos.camera.di.media + +import android.content.Context +import app.grapheneos.camera.data.media.repository.CapturedItemRepository +import app.grapheneos.camera.data.media.repository.CapturedItemRepositoryImpl +import app.grapheneos.camera.data.media.repository.LockscreenCapturedItemRepository +import app.grapheneos.camera.ui.activities.SecureActivity +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +internal class MediaProvidesModule { + + @Provides + @ActivityScoped + fun provideCapturedItemRepository( + @ActivityContext context: Context, + repository: CapturedItemRepositoryImpl, + ): CapturedItemRepository { + return when (context) { + is SecureActivity -> LockscreenCapturedItemRepository(repository) + else -> repository + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/di/preferences/DurablePreferencesProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/preferences/DurablePreferencesProvidesModule.kt new file mode 100644 index 00000000..e21f88ef --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/preferences/DurablePreferencesProvidesModule.kt @@ -0,0 +1,86 @@ +package app.grapheneos.camera.di.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStoreFile +import app.grapheneos.camera.data.media.store.MediaPrefs +import app.grapheneos.camera.data.media.store.MediaPrefsMigration +import app.grapheneos.camera.data.media.store.StoragePrefs +import app.grapheneos.camera.data.media.store.StoragePrefsMigration +import app.grapheneos.camera.data.media.store.mediaPrefsSerializer +import app.grapheneos.camera.data.media.store.storagePrefsSerializer +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.data.settings.store.SettingsPrefsMigration +import app.grapheneos.camera.data.settings.store.settingsPrefsSerializer +import app.grapheneos.camera.di.core.ApplicationScope +import app.grapheneos.camera.di.core.DurablePreferences +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope + +@Module +@InstallIn(SingletonComponent::class) +internal class DurablePreferencesProvidesModule { + + @Provides + @Singleton + @DurablePreferences + fun provideDurableSettingsPrefs( + @ApplicationContext context: Context, + @ApplicationScope scope: CoroutineScope, + ): DataStore { + return DataStoreFactory.create( + serializer = settingsPrefsSerializer, + corruptionHandler = ReplaceFileCorruptionHandler { SettingsPrefs() }, + migrations = listOf(SettingsPrefsMigration(context)), + scope = scope, + ) { + context.dataStoreFile(SETTINGS_PREFS_FILE_NAME) + } + } + + @Provides + @Singleton + @DurablePreferences + fun provideDurableStoragePrefs( + @ApplicationContext context: Context, + @ApplicationScope scope: CoroutineScope, + ): DataStore { + return DataStoreFactory.create( + serializer = storagePrefsSerializer, + corruptionHandler = ReplaceFileCorruptionHandler { StoragePrefs() }, + migrations = listOf(StoragePrefsMigration(context)), + scope = scope, + ) { + context.dataStoreFile(STORAGE_PREFS_FILE_NAME) + } + } + + @Provides + @Singleton + fun provideDurableMediaPrefs( + @ApplicationContext context: Context, + @ApplicationScope scope: CoroutineScope, + ): DataStore { + return DataStoreFactory.create( + serializer = mediaPrefsSerializer, + corruptionHandler = ReplaceFileCorruptionHandler { MediaPrefs() }, + migrations = listOf(MediaPrefsMigration(context)), + scope = scope, + ) { + context.dataStoreFile(MEDIA_PREFS_FILE_NAME) + } + } + + private companion object { + private const val SETTINGS_PREFS_FILE_NAME = "settings_prefs.json" + private const val STORAGE_PREFS_FILE_NAME = "storage_prefs.json" + private const val MEDIA_PREFS_FILE_NAME = "media_prefs.json" + } +} diff --git a/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt new file mode 100644 index 00000000..49cbdfad --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt @@ -0,0 +1,47 @@ +package app.grapheneos.camera.di.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import app.grapheneos.camera.data.core.store.InMemoryDataStore +import app.grapheneos.camera.data.media.store.StoragePrefs +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.di.core.DurablePreferences +import app.grapheneos.camera.ui.activities.SecureActivity +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +@Module +@InstallIn(ActivityComponent::class) +internal class PreferencesProvidesModule { + + @Provides + @ActivityScoped + fun provideSettingsPrefs( + @ActivityContext context: Context, + @DurablePreferences durable: DataStore, + ): DataStore { + return when (context) { + // Secure sessions get a snapshot so later owner changes cannot leak through the lockscreen. + is SecureActivity -> InMemoryDataStore(runBlocking { durable.data.first() }) + else -> durable + } + } + + @Provides + @ActivityScoped + fun provideStoragePrefs( + @ActivityContext context: Context, + @DurablePreferences durable: DataStore, + ): DataStore { + return when (context) { + is SecureActivity -> InMemoryDataStore(runBlocking { durable.data.first() }) + else -> durable + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/di/settings/SettingsBindsModule.kt b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsBindsModule.kt new file mode 100644 index 00000000..2c3db413 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsBindsModule.kt @@ -0,0 +1,20 @@ +package app.grapheneos.camera.di.settings + +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import app.grapheneos.camera.data.settings.repository.SettingsRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +internal abstract class SettingsBindsModule { + + @Binds + @ActivityScoped + abstract fun bindSettingsRepository( + impl: SettingsRepositoryImpl, + ): SettingsRepository +} diff --git a/app/src/main/java/app/grapheneos/camera/di/settings/SettingsMapperBindsModule.kt b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsMapperBindsModule.kt new file mode 100644 index 00000000..4d813e28 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsMapperBindsModule.kt @@ -0,0 +1,36 @@ +package app.grapheneos.camera.di.settings + +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapper +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapperImpl +import app.grapheneos.camera.data.settings.mapper.ModeSettingsMapper +import app.grapheneos.camera.data.settings.mapper.ModeSettingsMapperImpl +import app.grapheneos.camera.data.settings.mapper.StoredVideoQualityMapper +import app.grapheneos.camera.data.settings.mapper.StoredVideoQualityMapperImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class SettingsMapperBindsModule { + + @Binds + @Reusable + abstract fun bindCameraSettingsMapper( + impl: CameraSettingsMapperImpl, + ): CameraSettingsMapper + + @Binds + @Reusable + abstract fun bindModeSettingsMapper( + impl: ModeSettingsMapperImpl, + ): ModeSettingsMapper + + @Binds + @Reusable + abstract fun bindStoredVideoQualityMapper( + impl: StoredVideoQualityMapperImpl, + ): StoredVideoQualityMapper +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/CustomGrid.kt b/app/src/main/java/app/grapheneos/camera/ui/CustomGrid.kt index 4510c656..7a65d8c8 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/CustomGrid.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/CustomGrid.kt @@ -7,6 +7,7 @@ import android.graphics.Paint import android.util.AttributeSet import android.view.View import app.grapheneos.camera.CamConfig +import app.grapheneos.camera.data.settings.model.GridType import app.grapheneos.camera.ui.activities.MainActivity class CustomGrid @JvmOverloads constructor( @@ -34,11 +35,11 @@ class CustomGrid @JvmOverloads constructor( super.onDraw(canvas) - if (camConfig.gridType == CamConfig.GridType.NONE) { + if (camConfig.gridType == GridType.NONE) { return } - if (camConfig.gridType == CamConfig.GridType.GOLDEN_RATIO) { + if (camConfig.gridType == GridType.GOLDEN_RATIO) { val cx = width / 2f val cy = height / 2f @@ -53,7 +54,7 @@ class CustomGrid @JvmOverloads constructor( } else { - val seed = if (camConfig.gridType == CamConfig.GridType.THREE_BY_THREE) { + val seed = if (camConfig.gridType == GridType.THREE_BY_THREE) { 3f } else { 4f diff --git a/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt index 1fab3386..398f12bd 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt @@ -43,6 +43,9 @@ import androidx.core.graphics.ColorUtils import androidx.core.view.ViewCompat import app.grapheneos.camera.CamConfig import app.grapheneos.camera.R +import app.grapheneos.camera.data.settings.model.GridType +import app.grapheneos.camera.data.settings.model.videoQualityFromTitle +import app.grapheneos.camera.data.settings.model.videoQualityTitle import app.grapheneos.camera.databinding.SettingsBinding import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.MoreSettings @@ -219,10 +222,10 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : gridToggle = binding.gridToggleOption gridToggle.setOnClickListener { camConfig.gridType = when (camConfig.gridType) { - CamConfig.GridType.NONE -> CamConfig.GridType.THREE_BY_THREE - CamConfig.GridType.THREE_BY_THREE -> CamConfig.GridType.FOUR_BY_FOUR - CamConfig.GridType.FOUR_BY_FOUR -> CamConfig.GridType.GOLDEN_RATIO - CamConfig.GridType.GOLDEN_RATIO -> CamConfig.GridType.NONE + GridType.NONE -> GridType.THREE_BY_THREE + GridType.THREE_BY_THREE -> GridType.FOUR_BY_FOUR + GridType.FOUR_BY_FOUR -> GridType.GOLDEN_RATIO + GridType.GOLDEN_RATIO -> GridType.NONE } updateGridToggleUI() } @@ -550,18 +553,13 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : private fun updateTimerDuration(duration: Int) { mActivity.timerDuration = duration mActivity.updateSelfTimerBadge() - // commonPref rather than modePref: the self-timer is not per-mode, and modePref is not - // assigned until the camera starts, which happens after this dialog is built. - camConfig.commonPref.edit() - .putInt(CamConfig.SettingValues.Key.SELF_TIMER_DURATION, duration) - .apply() + // Common rather than per-mode: a mode's preferences are not slotted until the camera + // starts, which happens after this dialog is built. + camConfig.selfTimerDuration = duration } private fun restoreTimerDuration() { - val duration = camConfig.commonPref.getInt( - CamConfig.SettingValues.Key.SELF_TIMER_DURATION, - CamConfig.SettingValues.Default.SELF_TIMER_DURATION - ) + val duration = camConfig.selfTimerDuration // Apply directly: Spinner.setSelection() only posts its selection callback, so the duration // would otherwise stay unset for a looper pass. updateTimerDuration(duration) @@ -572,7 +570,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : fun updateVideoQuality(choice: String, resCam: Boolean = true) { - val quality = titleToQuality(choice) + val quality = videoQualityFromTitle(choice) if (quality == camConfig.videoQuality) return @@ -586,19 +584,6 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } } - fun titleToQuality(title: String): Quality { - return when (title) { - "2160p (UHD)" -> Quality.UHD - "1080p (FHD)" -> Quality.FHD - "720p (HD)" -> Quality.HD - "480p (SD)" -> Quality.SD - else -> { - Log.e("TAG", "Unknown quality: $title") - Quality.SD - } - } - } - private var wasSelfIlluminationOn = false fun selfIllumination() { @@ -789,34 +774,21 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : val titles = arrayListOf() getAvailableQualities().forEach { - titles.add(getTitleFor(it)) + titles.add(videoQualityTitle(it)) } return titles } - private fun getTitleFor(quality: Quality): String { - return when (quality) { - Quality.UHD -> "2160p (UHD)" - Quality.FHD -> "1080p (FHD)" - Quality.HD -> "720p (HD)" - Quality.SD -> "480p (SD)" - else -> { - Log.i("TAG", "Unknown constant: $quality") - "Unknown" - } - } - } - fun updateGridToggleUI() { mActivity.previewGrid.postInvalidate() // The description has to travel with the drawable: this control cycles through four // states, so a fixed "Grid Toggle" label left a screen reader unable to report any of them val (icon, description) = when (camConfig.gridType) { - CamConfig.GridType.NONE -> R.drawable.grid_off_circle to R.string.grid_off - CamConfig.GridType.THREE_BY_THREE -> R.drawable.grid_3x3_circle to R.string.grid_3x3 - CamConfig.GridType.FOUR_BY_FOUR -> R.drawable.grid_4x4_circle to R.string.grid_4x4 - CamConfig.GridType.GOLDEN_RATIO -> + GridType.NONE -> R.drawable.grid_off_circle to R.string.grid_off + GridType.THREE_BY_THREE -> R.drawable.grid_3x3_circle to R.string.grid_3x3 + GridType.FOUR_BY_FOUR -> R.drawable.grid_4x4_circle to R.string.grid_4x4 + GridType.GOLDEN_RATIO -> R.drawable.grid_goldenratio_circle to R.string.grid_golden_ratio } gridToggle.setImageResource(icon) @@ -892,7 +864,9 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : videoQualitySpinner.adapter = vQAdapter if (camConfig.videoQuality != Quality.HIGHEST) { - videoQualitySpinner.setSelection(titles.indexOf(getTitleFor(camConfig.videoQuality))) + videoQualitySpinner.setSelection( + titles.indexOf(videoQualityTitle(camConfig.videoQuality)), + ) } } } diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt index 49828bb2..209f4c24 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt @@ -39,11 +39,11 @@ import androidx.viewpager2.widget.ViewPager2 import androidxc.exifinterface.media.ExifInterface import app.grapheneos.camera.AutoFinishOnSleep import app.grapheneos.camera.CapturedItem -import app.grapheneos.camera.CapturedItems import app.grapheneos.camera.GSlideTransformer import app.grapheneos.camera.GallerySliderAdapter import app.grapheneos.camera.ITEM_TYPE_VIDEO import app.grapheneos.camera.R +import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.databinding.GalleryBinding import app.grapheneos.camera.editCapturedItem import app.grapheneos.camera.shareCapturedItem @@ -53,16 +53,23 @@ import app.grapheneos.camera.util.getParcelableExtra import app.grapheneos.camera.util.storageLocationToUiString import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint import java.text.ParseException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone import java.util.concurrent.Executors +import javax.inject.Inject import kotlin.properties.Delegates +import kotlinx.coroutines.runBlocking +@AndroidEntryPoint class InAppGallery : AppCompatActivity() { + @Inject + lateinit var capturedItemRepository: CapturedItemRepository + lateinit var binding: GalleryBinding lateinit var gallerySlider: ViewPager2 var gallerySliderAdapter: GallerySliderAdapter? = null @@ -661,7 +668,7 @@ class InAppGallery : AppCompatActivity() { asyncLoaderOfCapturedItems.execute { val unprocessedItems: List = try { - CapturedItems.get(this) + runBlocking { capturedItemRepository.capturedItems() } } catch (_: InterruptedException) { // activity was destroyed and exectutor.shutdownNow() was called, which interrupts // executor threads diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt index 5bb87c2b..147daf73 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt @@ -78,6 +78,8 @@ import app.grapheneos.camera.capturer.ImageCapturer import app.grapheneos.camera.capturer.VideoCapturer import app.grapheneos.camera.capturer.getVideoThumbnail import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.media.repository.CapturedItemRepository +import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.shareCapturedItem import app.grapheneos.camera.databinding.ActivityMainBinding import app.grapheneos.camera.databinding.ScanResultDialogBinding @@ -104,11 +106,13 @@ import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayout import com.google.zxing.BarcodeFormat +import dagger.hilt.android.AndroidEntryPoint import java.io.File import java.nio.charset.StandardCharsets import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject import kotlin.math.abs import kotlin.math.max import kotlin.math.roundToInt @@ -117,6 +121,7 @@ import androidx.core.net.toUri import androidx.core.view.isVisible import androidx.core.graphics.createBitmap +@AndroidEntryPoint open class MainActivity : AppCompatActivity(), OnTouchListener, OnScaleGestureListener, @@ -124,6 +129,12 @@ open class MainActivity : AppCompatActivity(), GestureDetector.OnDoubleTapListener, SensorOrientationChangeNotifier.Listener { + @Inject + lateinit var settingsRepository: SettingsRepository + + @Inject + lateinit var capturedItemRepository: CapturedItemRepository + private val application: App get() = applicationContext as App @@ -714,7 +725,7 @@ open class MainActivity : AppCompatActivity(), } if (this !is SecureActivity) { - camConfig.fetchLastCapturedItemFromSharedPrefs() + camConfig.fetchLastCapturedItem() } updateThumbnail() @@ -773,7 +784,11 @@ open class MainActivity : AppCompatActivity(), gestureDetector = GestureDetector(this, this) - camConfig = CamConfig(this) + camConfig = CamConfig( + mActivity = this, + settingsRepository = settingsRepository, + capturedItemRepository = capturedItemRepository, + ) cameraControl = CameraControl(camConfig) mainOverlay = binding.mainOverlay imageCapturer = ImageCapturer(this) @@ -1894,6 +1909,7 @@ open class MainActivity : AppCompatActivity(), SensorOrientationChangeNotifier.clearInstance() thumbnailLoaderExecutor.shutdownNow() frameCopyThread?.quitSafely() + camConfig.onDestroy() } fun locationCamConfigChanged(required: Boolean) { diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt index ef5c03b7..a8c969af 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt @@ -23,6 +23,7 @@ import app.grapheneos.camera.CamConfig import app.grapheneos.camera.CapturedItems import app.grapheneos.camera.NumInputFilter import app.grapheneos.camera.R +import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.databinding.MoreSettingsBinding import app.grapheneos.camera.util.storageLocationToUiString import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -127,7 +128,7 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { dialog.setMessage(R.string.revert_to_default_directory) dialog.setPositiveButton(R.string.yes) { _, _ -> - val defaultLocation = CamConfig.SettingValues.Default.STORAGE_LOCATION + val defaultLocation = CapturedItemRepository.MEDIA_STORE_LOCATION if (camConfig.storageLocation != defaultLocation) { showMessage(getString(R.string.reverted_to_default_directory)) diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettingsSecure.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettingsSecure.kt index b0c1ff95..838f1cf4 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettingsSecure.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettingsSecure.kt @@ -3,7 +3,7 @@ package app.grapheneos.camera.ui.activities import android.os.Bundle import app.grapheneos.camera.AutoFinishOnSleep -class MoreSettingsSecure : MoreSettings() { +class MoreSettingsSecure : MoreSettings(), SecureActivity { private val autoFinisher = AutoFinishOnSleep(this) diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/SecureActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/SecureActivity.kt index bbd919e3..ecf63c44 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/SecureActivity.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/SecureActivity.kt @@ -1,7 +1,7 @@ package app.grapheneos.camera.ui.activities -import android.content.SharedPreferences - -interface SecureActivity { - fun getSharedPreferences(name: String, mode: Int): SharedPreferences? = null -} +/** + * Marks an entry point that can be reached from the lockscreen, and so runs for whoever is holding + * the phone rather than for its owner. + */ +interface SecureActivity diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/SecureCaptureActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/SecureCaptureActivity.kt index 50424268..106f7fe0 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/SecureCaptureActivity.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/SecureCaptureActivity.kt @@ -1,13 +1,3 @@ package app.grapheneos.camera.ui.activities -import android.content.SharedPreferences -import app.grapheneos.camera.util.EphemeralSharedPrefsNamespace -import app.grapheneos.camera.util.getPrefs - -class SecureCaptureActivity : CaptureActivity(), SecureActivity { - val ephemeralPrefsNamespace = EphemeralSharedPrefsNamespace() - - override fun getSharedPreferences(name: String, mode: Int): SharedPreferences { - return ephemeralPrefsNamespace.getPrefs(this, name, mode, cloneOriginal = true) - } -} +class SecureCaptureActivity : CaptureActivity(), SecureActivity diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/SecureMainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/SecureMainActivity.kt index b362222e..cd78317c 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/SecureMainActivity.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/SecureMainActivity.kt @@ -1,15 +1,11 @@ package app.grapheneos.camera.ui.activities -import android.content.SharedPreferences import android.os.Bundle import app.grapheneos.camera.AutoFinishOnSleep import app.grapheneos.camera.CapturedItem -import app.grapheneos.camera.util.EphemeralSharedPrefsNamespace -import app.grapheneos.camera.util.getPrefs open class SecureMainActivity : MainActivity(), SecureActivity { val capturedItems = ArrayList() - val ephemeralPrefsNamespace = EphemeralSharedPrefsNamespace() private val autoFinisher = AutoFinishOnSleep(this) @@ -22,8 +18,4 @@ open class SecureMainActivity : MainActivity(), SecureActivity { super.onDestroy() autoFinisher.stop() } - - override fun getSharedPreferences(name: String, mode: Int): SharedPreferences { - return ephemeralPrefsNamespace.getPrefs(this, name, mode, cloneOriginal = true) - } } diff --git a/app/src/main/java/app/grapheneos/camera/util/SharedPrefs.kt b/app/src/main/java/app/grapheneos/camera/util/SharedPrefs.kt deleted file mode 100644 index 9da49d2e..00000000 --- a/app/src/main/java/app/grapheneos/camera/util/SharedPrefs.kt +++ /dev/null @@ -1,166 +0,0 @@ -package app.grapheneos.camera.util - -import android.annotation.SuppressLint -import android.content.Context -import android.content.SharedPreferences -import android.os.Build -import android.util.ArrayMap -import java.util.WeakHashMap - -import android.content.SharedPreferences.OnSharedPreferenceChangeListener as ChangeListener - -typealias EphemeralSharedPrefsNamespace = ArrayMap - -fun EphemeralSharedPrefsNamespace.getPrefs(ctx: Context, name: String, mode: Int, cloneOriginal: Boolean): SharedPreferences { - require(mode == Context.MODE_PRIVATE) - synchronized(this) { - return getOrElse(name) { - val prefs = EphemeralSharedPrefs(ctx.applicationInfo.targetSdkVersion) - - if (cloneOriginal) { - val orig = ctx.applicationContext.getSharedPreferences(name, Context.MODE_PRIVATE) - orig.all.forEach { k, v -> - prefs.map[k] = v - } - } - - this[name] = prefs - - prefs - } - } -} - -class EphemeralSharedPrefs(val targetSdk: Int) : SharedPreferences { - - internal val map = HashMap() - // match the "weakly referenced listeners" behavior of the regular SharedPreferences, - // there's no WeakSet, approximate it by using a dummy value - internal val listeners = WeakHashMap() - - override fun getAll(): MutableMap = map - - @Suppress("UNCHECKED_CAST") - private fun get(key: String?, defValue: T?): T? { - synchronized(this) { - return map[key!!] as T ?: defValue - } - } - - override fun getString(key: String?, defValue: String?): String? = get(key, defValue) - override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = get(key, defValues) - override fun getInt(key: String?, defValue: Int): Int = get(key, defValue) ?: defValue - override fun getLong(key: String?, defValue: Long): Long = get(key, defValue) ?: defValue - override fun getFloat(key: String?, defValue: Float): Float = get(key, defValue) ?: defValue - override fun getBoolean(key: String?, defValue: Boolean): Boolean = get(key, defValue) ?: defValue - - override fun contains(key: String?): Boolean = map.contains(key) - - override fun edit(): SharedPreferences.Editor = Editor(this) - - override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { - synchronized(this) { - listeners[listener] = this - } - } - - override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { - synchronized(this) { - listeners.remove(listener) - } - } - - private class Editor(val prefs: EphemeralSharedPrefs) : SharedPreferences.Editor { - val map = HashMap() - private val removedKeys = arrayListOf() - val thread = Thread.currentThread() - - private var cleared = false - - private fun checkThread() { - check(Thread.currentThread() === thread) - } - - private fun put(key: String?, value: T?): Editor { - checkThread() - map[key!!] = value - return this - } - - override fun putString(key: String?, value: String?) = put(key, value) - override fun putStringSet(key: String?, values: MutableSet?) = put(key, values) - override fun putInt(key: String?, value: Int) = put(key, value) - override fun putLong(key: String?, value: Long) = put(key, value) - override fun putFloat(key: String?, value: Float) = put(key, value) - override fun putBoolean(key: String?, value: Boolean) = put(key, value) - - override fun remove(key: String?): SharedPreferences.Editor { - checkThread() - removedKeys.add(key!!) - return this - } - - override fun clear(): SharedPreferences.Editor { - checkThread() - cleared = true - return this - } - - override fun commit(): Boolean { - apply() - return true - } - - override fun apply() { - checkThread() - val listeners: Set - - synchronized(prefs) { - listeners = prefs.listeners.keys - - if (cleared) { - prefs.map.clear() - } - removedKeys.forEach { key -> - prefs.map.remove(key) - } - map.forEach { k, v -> - prefs.map[k] = v - } - } - - // notify listeners outside the critical section - - if (cleared) { - // see onSharedPreferenceChanged() doc - if (prefs.targetSdk >= Build.VERSION_CODES.R) { - listeners.forEach { - it.onSharedPreferenceChanged(prefs, null) - } - } - } - removedKeys.forEach { key -> - listeners.forEach { - it.onSharedPreferenceChanged(prefs, key) - } - } - map.forEach { k, _ -> - listeners.forEach { - it.onSharedPreferenceChanged(prefs, k) - } - } - } - } -} - -@SuppressLint("ApplySharedPref") -inline fun SharedPreferences.edit(commit: Boolean = false, - action: SharedPreferences.Editor.() -> Unit) { - val editor = edit() - action(editor) - if (commit) { - editor.commit() - } else { - editor.apply() - } -} diff --git a/app/src/main/java/app/grapheneos/camera/util/Utils.kt b/app/src/main/java/app/grapheneos/camera/util/Utils.kt index 34529b0b..416014fc 100644 --- a/app/src/main/java/app/grapheneos/camera/util/Utils.kt +++ b/app/src/main/java/app/grapheneos/camera/util/Utils.kt @@ -11,6 +11,7 @@ import app.grapheneos.camera.CamConfig import app.grapheneos.camera.R import app.grapheneos.camera.capturer.DEFAULT_MEDIA_STORE_CAPTURE_PATH import app.grapheneos.camera.capturer.SAF_URI_HOST_EXTERNAL_STORAGE +import app.grapheneos.camera.data.media.repository.CapturedItemRepository import java.io.ByteArrayOutputStream import java.io.IOException import java.io.PrintStream @@ -51,7 +52,7 @@ fun ExecutorService.executeIfAlive(r: Runnable) { } fun storageLocationToUiString(ctx: Context, sl: String): String { - if (sl == CamConfig.SettingValues.Default.STORAGE_LOCATION) { + if (sl == CapturedItemRepository.MEDIA_STORE_LOCATION) { return "${ctx.getString(R.string.main_storage)}/$DEFAULT_MEDIA_STORE_CAPTURE_PATH" } diff --git a/app/src/test/java/app/grapheneos/camera/data/core/InMemoryDataStoreTest.kt b/app/src/test/java/app/grapheneos/camera/data/core/InMemoryDataStoreTest.kt new file mode 100644 index 00000000..8a1c2dbe --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/core/InMemoryDataStoreTest.kt @@ -0,0 +1,62 @@ +package app.grapheneos.camera.data.core + +import app.grapheneos.camera.data.core.store.InMemoryDataStore +import kotlin.concurrent.thread +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class InMemoryDataStoreTest { + + private val dataStore = InMemoryDataStore(StoredValue()) + + private fun stored(): StoredValue { + return runBlocking { dataStore.data.first() } + } + + @Test + fun updateData_transform_runsAgainstWhatIsStored() { + runBlocking { dataStore.updateData { it.copy(name = SOME_NAME) } } + + val returned = runBlocking { dataStore.updateData { it.copy(written = listOf(it.name)) } } + + assertEquals(listOf(SOME_NAME), returned.written) + assertEquals(listOf(SOME_NAME), stored().written) + } + + @Test + fun updateData_transformThrows_leavesTheStoredValueAlone() { + runBlocking { dataStore.updateData { it.copy(name = SOME_NAME) } } + + assertThrows(IllegalStateException::class.java) { + runBlocking { dataStore.updateData { error("the transform failed") } } + } + + assertEquals(SOME_NAME, stored().name) + } + + @Test + fun updateData_concurrentWrites_eachSeeTheOnesBeforeThem() { + val writers = (1..WRITER_COUNT).map { index -> + thread { + runBlocking { dataStore.updateData { it.copy(written = it.written + "$index") } } + } + } + + writers.forEach { it.join() } + + assertEquals((1..WRITER_COUNT).map { "$it" }.toSet(), stored().written.toSet()) + } + + private data class StoredValue( + val name: String = "", + val written: List = emptyList(), + ) + + private companion object { + const val SOME_NAME = "stored before the transform ran" + const val WRITER_COUNT = 16 + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/core/JsonPreferenceSerializerTest.kt b/app/src/test/java/app/grapheneos/camera/data/core/JsonPreferenceSerializerTest.kt new file mode 100644 index 00000000..18e954cb --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/core/JsonPreferenceSerializerTest.kt @@ -0,0 +1,64 @@ +package app.grapheneos.camera.data.core + +import androidx.datastore.core.CorruptionException +import app.grapheneos.camera.data.core.store.JsonPreferenceSerializer +import java.io.ByteArrayOutputStream +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class JsonPreferenceSerializerTest { + + private val serializer = JsonPreferenceSerializer( + serializer = StoredValue.serializer(), + defaultValue = StoredValue(), + ) + + private fun readFrom(stored: String): StoredValue { + return runBlocking { serializer.readFrom(stored.encodeToByteArray().inputStream()) } + } + + private fun assertReportedAsCorruption(stored: String) { + assertThrows(CorruptionException::class.java) { readFrom(stored) } + } + + @Test + fun readFrom_whatWasWritten_readsBackUnchanged() { + val output = ByteArrayOutputStream() + + runBlocking { serializer.writeTo(StoredValue(name = SOME_NAME), output) } + + assertEquals(SOME_NAME, readFrom(output.toByteArray().decodeToString()).name) + } + + @Test + fun readFrom_anEmptyFile_isReportedAsCorruption() { + assertReportedAsCorruption("") + } + + @Test + fun readFrom_bytesThatAreNotJsonAtAll_isReportedAsCorruption() { + assertReportedAsCorruption("\u0000\u0001 not preferences") + } + + @Test + fun readFrom_jsonLeftHalfWrittenByAnInterruptedWrite_isReportedAsCorruption() { + assertReportedAsCorruption("""{"name":"half of a wr""") + } + + @Test + fun readFrom_aFieldHoldingTheWrongType_isReportedAsCorruption() { + assertReportedAsCorruption("""{"name":42}""") + } + + @Serializable + private data class StoredValue( + val name: String = "", + ) + + private companion object { + const val SOME_NAME = "written before the file was damaged" + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/core/LegacyMigrationOwnershipTest.kt b/app/src/test/java/app/grapheneos/camera/data/core/LegacyMigrationOwnershipTest.kt new file mode 100644 index 00000000..dade2995 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/core/LegacyMigrationOwnershipTest.kt @@ -0,0 +1,145 @@ +package app.grapheneos.camera.data.core + +import android.content.Context +import androidx.datastore.core.DataMigration +import androidx.test.core.app.ApplicationProvider +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.media.store.MediaPrefs +import app.grapheneos.camera.data.media.store.MediaPrefsMigration +import app.grapheneos.camera.data.media.store.StoragePrefs +import app.grapheneos.camera.data.media.store.StoragePrefsMigration +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.data.settings.store.SettingsPrefsMigration +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class LegacyMigrationOwnershipTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val settings = SettingsPrefsMigration(context) + + private val storage = StoragePrefsMigration(context) + + private val media = MediaPrefsMigration(context) + + @Before + fun clearLegacyFiles() { + clearLegacyPreferences(context) + } + + @Test + fun migrations_inAnyOrder_produceTheSameResultAndLeaveNoLegacyFile() { + val migrated = ORDERS.map { order -> migrateIn(order) } + + migrated.forEach { assertEquals(migrated.first(), it) } + } + + @Test + fun legacyKeyOwnership_isDisjointAndComplete() { + val settingsOwned = keysRemovedBy { runBlocking { settings.cleanUp() } } + val storageOwned = keysRemovedBy { runBlocking { storage.cleanUp() } } + val mediaOwned = keysRemovedBy { runBlocking { media.cleanUp() } } + + assertEquals(emptySet(), settingsOwned.intersect(storageOwned)) + assertEquals(emptySet(), settingsOwned.intersect(mediaOwned)) + assertEquals(emptySet(), storageOwned.intersect(mediaOwned)) + assertEquals(LEGACY_COMMON_ENTRIES.keys, settingsOwned + storageOwned + mediaOwned) + assertEquals(LEGACY_STORAGE_KEY_NAMES, storageOwned) + assertEquals(LEGACY_CAPTURE_KEY_NAMES, mediaOwned) + } + + @Test + fun cleanUp_lastOwnerToRun_deletesTheCommonsFile() { + writeLegacyPreferences(context) + + runBlocking { media.cleanUp() } + + assertTrue("two owners have still to read", legacyCommonsFileExists(context)) + + runBlocking { storage.cleanUp() } + + assertTrue("the settings have still to be read", legacyCommonsFileExists(context)) + + runBlocking { settings.cleanUp() } + + assertFalse(legacyCommonsFileExists(context)) + } + + private fun migrateIn(order: List): Migrated { + clearLegacyPreferences(context) + writeLegacyPreferences(context) + + val migrated = order.fold(Migrated()) { carried, owner -> migrate(owner, carried) } + + assertNoLegacyFileLeft(order.joinToString()) + + return migrated + } + + private fun migrate(owner: Owner, into: Migrated): Migrated { + return when (owner) { + Owner.SETTINGS -> into.copy(settings = carryOver(settings, SettingsPrefs())) + Owner.STORAGE -> into.copy(storage = carryOver(storage, StoragePrefs())) + Owner.MEDIA -> into.copy(media = carryOver(media, MediaPrefs())) + } + } + + private fun carryOver(migration: DataMigration, freshInstall: T): T { + return runBlocking { + assertTrue("nothing to migrate", migration.shouldMigrate(freshInstall)) + + val migrated = migration.migrate(freshInstall) + migration.cleanUp() + migrated + } + } + + private fun keysRemovedBy(cleanUp: () -> Unit): Set { + clearLegacyPreferences(context) + writeLegacyPreferences(context) + + val before = legacyCommonsFile(context).all.keys.toSet() + cleanUp() + + return before - legacyCommonsFile(context).all.keys + } + + private fun assertNoLegacyFileLeft(order: String) { + assertFalse(order, legacyCommonsFileExists(context)) + + CameraMode.entries.forEach { mode -> + assertFalse("$order, ${mode.name}", legacyModeFileExists(context, mode)) + } + } + + private enum class Owner { + SETTINGS, + STORAGE, + MEDIA, + } + + private data class Migrated( + val settings: SettingsPrefs? = null, + val storage: StoragePrefs? = null, + val media: MediaPrefs? = null, + ) + + private companion object { + val ORDERS = listOf( + listOf(Owner.SETTINGS, Owner.STORAGE, Owner.MEDIA), + listOf(Owner.SETTINGS, Owner.MEDIA, Owner.STORAGE), + listOf(Owner.STORAGE, Owner.SETTINGS, Owner.MEDIA), + listOf(Owner.STORAGE, Owner.MEDIA, Owner.SETTINGS), + listOf(Owner.MEDIA, Owner.SETTINGS, Owner.STORAGE), + listOf(Owner.MEDIA, Owner.STORAGE, Owner.SETTINGS), + ) + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/core/LegacyPreferences.kt b/app/src/test/java/app/grapheneos/camera/data/core/LegacyPreferences.kt new file mode 100644 index 00000000..ec9919a1 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/core/LegacyPreferences.kt @@ -0,0 +1,125 @@ +package app.grapheneos.camera.data.core + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import app.grapheneos.camera.ITEM_TYPE_IMAGE +import app.grapheneos.camera.data.core.model.CameraMode +import java.io.File + +internal const val LEGACY_COMMON_PREFS_NAME = "commons" +internal const val LEGACY_MEDIA_PREFS_NAME = "media" + +internal val LEGACY_CAPTURE_KEY_NAMES = setOf( + "last_captured_item_type", + "last_captured_item_date_string", + "last_captured_item_uri", +) + +internal val LEGACY_STORAGE_KEY_NAMES = setOf( + "storage_location", + "previous_saf_trees", + "media_uri_s", +) + +internal const val LEGACY_ITEM_DATE_STRING = "20260724_153012_345" +internal const val LEGACY_ITEM_URI = "content://media/external/images/media/1" + +internal val LEGACY_COMMON_ENTRIES: Map = mapOf( + "aspect_ratio" to 1, + "camera_sounds" to false, + "emphasis_on_quality" to true, + "enable_eis" to true, + "enable_zsl" to true, + "focus_timeout" to "10s", + "grid" to 3, + "gyroscope_suggestions" to true, + "include_audio" to false, + "media_uri_s" to "content://tree/a/document/photo.jpg", + "photo_quality" to 71, + "previous_saf_trees" to "content://tree/a\u0000content://tree/b", + "remove_exif_after_capture" to true, + "save_image_as_preview" to true, + "save_video_as_preview" to true, + "scan_all_codes" to true, + "scan_AZTEC" to true, + "scan_CODE_39" to false, + "scan_QR_CODE" to true, + "select_highest_resolution" to true, + "self_timer_duration" to 5, + "storage_location" to "content://tree/current", + "wait_for_focus_lock" to false, + "last_captured_item_type" to ITEM_TYPE_IMAGE, + "last_captured_item_date_string" to LEGACY_ITEM_DATE_STRING, + "last_captured_item_uri" to LEGACY_ITEM_URI, +) + +internal val LEGACY_MODE_ENTRIES: Map = mapOf( + "flash_mode" to 1, + "geo_tagging" to true, + "self_illumination" to true, + "video_quality_BACK" to "1080p (FHD)", + "video_quality_FRONT" to "720p (HD)", +) + +internal fun legacyCommonsFile(context: Context): SharedPreferences { + return context.getSharedPreferences(LEGACY_COMMON_PREFS_NAME, Context.MODE_PRIVATE) +} + +internal fun legacyModeFile(context: Context, mode: CameraMode): SharedPreferences { + return context.getSharedPreferences(mode.name, Context.MODE_PRIVATE) +} + +internal fun legacyMediaFile(context: Context): SharedPreferences { + return context.getSharedPreferences(LEGACY_MEDIA_PREFS_NAME, Context.MODE_PRIVATE) +} + +internal fun writeLegacyPreferences(context: Context) { + legacyCommonsFile(context).edit(commit = true) { + LEGACY_COMMON_ENTRIES.forEach { (key, value) -> put(key, value) } + } + + CameraMode.entries.forEach { mode -> + legacyModeFile(context, mode).edit(commit = true) { + LEGACY_MODE_ENTRIES.forEach { (key, value) -> put(key, value) } + } + } +} + +internal fun legacyCommonsFileExists(context: Context): Boolean { + return legacyFile(context, LEGACY_COMMON_PREFS_NAME).exists() +} + +internal fun legacyModeFileExists(context: Context, mode: CameraMode): Boolean { + return legacyFile(context, mode.name).exists() +} + +internal fun legacyMediaFileExists(context: Context): Boolean { + return legacyFile(context, LEGACY_MEDIA_PREFS_NAME).exists() +} + +private fun legacyFile(context: Context, name: String): File { + return File(context.dataDir, "shared_prefs/$name.xml") +} + +internal fun clearLegacyPreferences(context: Context) { + legacyCommonsFile(context).edit(commit = true) { clear() } + context.deleteSharedPreferences(LEGACY_COMMON_PREFS_NAME) + + legacyMediaFile(context).edit(commit = true) { clear() } + context.deleteSharedPreferences(LEGACY_MEDIA_PREFS_NAME) + + CameraMode.entries.forEach { mode -> + legacyModeFile(context, mode).edit(commit = true) { clear() } + context.deleteSharedPreferences(mode.name) + } +} + +private fun SharedPreferences.Editor.put(key: String, value: Any) { + when (value) { + is Boolean -> putBoolean(key, value) + is Int -> putInt(key, value) + is String -> putString(key, value) + else -> error("a legacy file never held a ${value::class.simpleName}") + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/media/CapturedItemRepositoryTest.kt b/app/src/test/java/app/grapheneos/camera/data/media/CapturedItemRepositoryTest.kt new file mode 100644 index 00000000..b9bd5985 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/media/CapturedItemRepositoryTest.kt @@ -0,0 +1,346 @@ +package app.grapheneos.camera.data.media + +import android.content.ContentProvider +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.content.ContextWrapper +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.DocumentsContract +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.test.core.app.ApplicationProvider +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.CapturedItems +import app.grapheneos.camera.IMAGE_NAME_PREFIX +import app.grapheneos.camera.ITEM_TYPE_IMAGE +import app.grapheneos.camera.data.core.store.InMemoryDataStore +import app.grapheneos.camera.data.media.repository.CapturedItemRepositoryImpl +import app.grapheneos.camera.data.media.repository.LockscreenCapturedItemRepository +import app.grapheneos.camera.data.media.store.MediaPrefs +import app.grapheneos.camera.data.media.store.StoragePrefs +import app.grapheneos.camera.data.media.store.StoredCapturedItem +import app.grapheneos.camera.data.media.store.mediaPrefsSerializer +import java.io.File +import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CapturedItemRepositoryTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val fileExecutor = Executors.newSingleThreadExecutor() + + private val fileScope = CoroutineScope(SupervisorJob() + fileExecutor.asCoroutineDispatcher()) + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val storagePrefs: DataStore = InMemoryDataStore(StoragePrefs()) + + private val mediaPrefs: DataStore = InMemoryDataStore(MediaPrefs()) + + @After + fun stopWriting() { + fileScope.cancel() + fileExecutor.shutdownNow() + } + + private fun repository( + session: DataStore = storagePrefs, + media: DataStore = mediaPrefs, + context: Context = this.context, + ): CapturedItemRepositoryImpl { + return CapturedItemRepositoryImpl( + storagePrefs = session, + mediaPrefs = media, + context = context, + ) + } + + private fun stored(): StoragePrefs { + return runBlocking { storagePrefs.data.first() } + } + + private fun previousSafTrees(): List { + return stored().previousSafTrees.map { Uri.parse(it) } + } + + private fun lockscreenPrefs(): DataStore { + return InMemoryDataStore(stored()) + } + + private fun item(dateString: String): CapturedItem { + return CapturedItem( + type = ITEM_TYPE_IMAGE, + dateString = dateString, + uri = Uri.parse("content://media/external/images/media/1"), + ) + } + + private fun documentUri(treeId: String): Uri { + val tree = DocumentsContract.buildTreeDocumentUri(AUTHORITY, treeId) + return DocumentsContract.buildDocumentUriUsingTree(tree, "$treeId/photo.jpg") + } + + private fun treeUri(treeId: String): Uri { + return DocumentsContract.buildTreeDocumentUri(AUTHORITY, treeId) + } + + @Test + fun lastCapturedItem_afterRestart_returnsTheStoredItem() { + runBlocking { repository().saveLastCapturedItem(item(DATE_STRING)) } + + val reloaded = runBlocking { repository().lastCapturedItem() } + + assertEquals(item(DATE_STRING), reloaded) + assertEquals(ITEM_TYPE_IMAGE, reloaded?.type) + } + + @Test + fun lastCapturedItem_freshInstall_returnsNull() { + assertNull(runBlocking { repository().lastCapturedItem() }) + } + + @Test + fun lockscreenSession_captures_reachTheCapturesFileAndNothingElse() { + val file = File(temporaryFolder.root, "media_prefs.json") + val durableMedia = DataStoreFactory.create( + serializer = mediaPrefsSerializer, + scope = fileScope, + ) { + file + } + val session = repository(session = lockscreenPrefs(), media = durableMedia) + + runBlocking { + session.saveLastCapturedItem(item(DATE_STRING)) + session.setStorageLocation(treeUri("treeA").toString()) + } + + val onDisk = runBlocking { mediaPrefsSerializer.readFrom(file.inputStream()) } + + assertEquals(item(DATE_STRING), runBlocking { session.lastCapturedItem() }) + assertEquals( + StoredCapturedItem( + type = ITEM_TYPE_IMAGE, + dateString = DATE_STRING, + uri = item(DATE_STRING).uri.toString(), + ), + onDisk.lastCapturedItem, + ) + assertEquals(StoragePrefs(), stored()) + } + + @Test + fun releaseUntrackedSafTrees_lockscreenSession_touchesNoPersistedGrant() { + val repository = LockscreenCapturedItemRepository( + repository( + session = lockscreenPrefs(), + context = NoContentResolverContext(context), + ), + ) + + runBlocking { repository.releaseUntrackedSafTrees() } + } + + @Test + fun storageLocation_writtenThroughAnotherInstance_isVisible() { + val reader = repository() + val writer = repository() + val location = treeUri("treeA").toString() + + runBlocking { writer.setStorageLocation(location) } + + assertEquals(location, runBlocking { reader.storageLocation.first() }) + assertEquals(listOf(treeUri("treeA")), runBlocking { reader.trackedSafTrees() }) + } + + @Test + fun setStorageLocation_beyondTheCap_keepsTheMostRecentPreviousTreesOnly() { + val repository = repository() + val tracked = CapturedItems.MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES + + (0..tracked).forEach { index -> + runBlocking { repository.setStorageLocation(treeUri("tree$index").toString()) } + } + + assertEquals( + (tracked - 1 downTo 0).map { treeUri("tree$it") }, + previousSafTrees(), + ) + } + + @Test + fun setStorageLocation_sameTree_keepsThePreviousTreesUnchanged() { + val current = treeUri("current").toString() + val previous = listOf(treeUri("treeA").toString(), treeUri("treeB").toString()) + runBlocking { + storagePrefs.updateData { + StoragePrefs(storageLocation = current, previousSafTrees = previous) + } + } + + runBlocking { repository().setStorageLocation(current) } + + assertEquals(previous, stored().previousSafTrees) + } + + @Test + fun migrateStoredCaptures_legacyUris_becomeTrackedTreesOnce() { + writeLegacyMediaUris(listOf("treeA", "treeB")) + + runBlocking { repository().migrateStoredCaptures { } } + + assertNull(stored().legacyMediaUris) + assertEquals( + listOf(treeUri("treeA"), treeUri("treeB")), + previousSafTrees(), + ) + + runBlocking { + repository().setStorageLocation(treeUri("treeC").toString()) + repository().setStorageLocation(treeUri("treeD").toString()) + repository().migrateStoredCaptures { } + } + + assertEquals( + listOf(treeUri("treeC"), treeUri("treeA"), treeUri("treeB")), + previousSafTrees(), + ) + } + + @Test + fun migrateStoredCaptures_currentStorageLocation_isNotTrackedAsAPreviousOne() { + val location = treeUri("treeA").toString() + runBlocking { storagePrefs.updateData { it.copy(storageLocation = location) } } + writeLegacyMediaUris(listOf("treeA")) + + runBlocking { repository().migrateStoredCaptures { } } + + assertEquals(emptyList(), previousSafTrees()) + } + + @Test + fun migrateStoredCaptures_moreLegacyTreesThanTheCap_keepsThemAll() { + val cap = CapturedItems.MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES + val trees = (0..cap).map { "tree$it" } + writeLegacyMediaUris(trees) + + runBlocking { repository().migrateStoredCaptures { } } + + assertEquals(trees.map { treeUri(it) }, previousSafTrees()) + + runBlocking { + repository().setStorageLocation(treeUri("picked").toString()) + repository().setStorageLocation(treeUri("current").toString()) + } + + assertEquals( + listOf(treeUri("picked")) + trees.take(cap - 1).map { treeUri(it) }, + previousSafTrees(), + ) + } + + @Test + fun migrateStoredCaptures_legacyUris_reportTheMostRecentAsTheLastCapturedItem() { + Robolectric.buildContentProvider(FakeDocumentsProvider::class.java).create(AUTHORITY) + writeLegacyMediaUris(listOf("treeA")) + + var reported: CapturedItem? = null + runBlocking { repository().migrateStoredCaptures { reported = it } } + + assertEquals( + CapturedItem( + type = ITEM_TYPE_IMAGE, + dateString = DATE_STRING, + uri = documentUri("treeA"), + ), + reported, + ) + } + + private fun writeLegacyMediaUris(treeIds: List) { + val joined = treeIds.joinToString(separator = LEGACY_MEDIA_URI_SEPARATOR) { + documentUri(it).toString() + } + + runBlocking { storagePrefs.updateData { it.copy(legacyMediaUris = joined) } } + } + + private class NoContentResolverContext( + base: Context, + ) : ContextWrapper(base) { + + override fun getContentResolver(): ContentResolver { + throw AssertionError("a lockscreen session must not touch persisted grants") + } + } + + private class FakeDocumentsProvider : ContentProvider() { + + override fun onCreate(): Boolean { + return true + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + val cursor = MatrixCursor(arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) + cursor.addRow(arrayOf("$IMAGE_NAME_PREFIX$DATE_STRING.jpg")) + return cursor + } + + override fun getType(uri: Uri): String? { + return null + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + throw UnsupportedOperationException() + } + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array?, + ): Int { + throw UnsupportedOperationException() + } + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int { + throw UnsupportedOperationException() + } + } + + private companion object { + const val AUTHORITY = "com.example.documents" + const val DATE_STRING = "20260724_153012_345" + const val LEGACY_MEDIA_URI_SEPARATOR = ";" + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/media/MediaPrefsMigrationTest.kt b/app/src/test/java/app/grapheneos/camera/data/media/MediaPrefsMigrationTest.kt new file mode 100644 index 00000000..ae30dcc9 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/media/MediaPrefsMigrationTest.kt @@ -0,0 +1,184 @@ +package app.grapheneos.camera.data.media + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.test.core.app.ApplicationProvider +import app.grapheneos.camera.ITEM_TYPE_IMAGE +import app.grapheneos.camera.data.core.LEGACY_CAPTURE_KEY_NAMES +import app.grapheneos.camera.data.core.LEGACY_ITEM_DATE_STRING +import app.grapheneos.camera.data.core.LEGACY_ITEM_URI +import app.grapheneos.camera.data.core.clearLegacyPreferences +import app.grapheneos.camera.data.core.legacyCommonsFile +import app.grapheneos.camera.data.core.legacyMediaFile +import app.grapheneos.camera.data.core.legacyMediaFileExists +import app.grapheneos.camera.data.core.writeLegacyPreferences +import app.grapheneos.camera.data.media.store.MediaPrefs +import app.grapheneos.camera.data.media.store.MediaPrefsMigration +import app.grapheneos.camera.data.media.store.StoredCapturedItem +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MediaPrefsMigrationTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val migration = MediaPrefsMigration(context) + + @Before + fun clearLegacyFiles() { + clearLegacyPreferences(context) + } + + @Test + fun shouldMigrate_freshInstall_isFalse() { + assertFalse(runBlocking { migration.shouldMigrate(MediaPrefs()) }) + } + + @Test + fun migrate_lastCapturedItemFromTheOldFiles_isCarriedOver() { + writeLegacyPreferences(context) + + val migrated = runBlocking { + assertTrue("nothing to migrate", migration.shouldMigrate(MediaPrefs())) + migration.migrate(MediaPrefs()) + } + + assertEquals( + StoredCapturedItem( + type = ITEM_TYPE_IMAGE, + dateString = LEGACY_ITEM_DATE_STRING, + uri = LEGACY_ITEM_URI, + ), + migrated.lastCapturedItem, + ) + } + + @Test + fun migrate_lastCapturedItemFromTheMediaFile_isCarriedOverBeforeTheOlderFile() { + writeCapturedItem( + preferences = legacyCommonsFile(context), + dateString = COMMONS_DATE_STRING, + uri = COMMONS_URI, + ) + writeCapturedItem( + preferences = legacyMediaFile(context), + dateString = MEDIA_DATE_STRING, + uri = MEDIA_URI, + ) + + val migrated = runBlocking { migration.migrate(MediaPrefs()) } + + assertEquals(MEDIA_DATE_STRING, migrated.lastCapturedItem?.dateString) + assertEquals(MEDIA_URI, migrated.lastCapturedItem?.uri) + } + + @Test + fun migrate_populatedDataStore_isKeptBeforeEitherLegacyFile() { + val current = StoredCapturedItem( + type = ITEM_TYPE_IMAGE, + dateString = CURRENT_DATE_STRING, + uri = CURRENT_URI, + ) + writeCapturedItem( + preferences = legacyCommonsFile(context), + dateString = COMMONS_DATE_STRING, + uri = COMMONS_URI, + ) + writeCapturedItem( + preferences = legacyMediaFile(context), + dateString = MEDIA_DATE_STRING, + uri = MEDIA_URI, + ) + + assertEquals( + current, + runBlocking { + migration.migrate(MediaPrefs(lastCapturedItem = current)) + }.lastCapturedItem, + ) + } + + @Test + fun migrate_anIncompleteLegacyRecord_leavesWhatIsStoredAlone() { + legacyCommonsFile(context).edit(commit = true) { + putInt("last_captured_item_type", ITEM_TYPE_IMAGE) + } + + val migrated = runBlocking { migration.migrate(MediaPrefs()) } + + assertNull(migrated.lastCapturedItem) + } + + @Test + fun shouldMigrate_afterTheItemHasBeenCarriedOver_isFalse() { + writeLegacyPreferences(context) + + runBlocking { + migration.migrate(MediaPrefs()) + migration.cleanUp() + } + + assertFalse(runBlocking { migration.shouldMigrate(MediaPrefs()) }) + } + + @Test + fun shouldMigrate_onlyAnotherOwnersKeysArePresent_isFalse() { + legacyCommonsFile(context).edit(commit = true) { + putInt("photo_quality", SOME_PHOTO_QUALITY) + } + + assertFalse(runBlocking { migration.shouldMigrate(MediaPrefs()) }) + } + + @Test + fun cleanUp_removesOnlyItsOwnKeys() { + writeLegacyPreferences(context) + writeCapturedItem( + preferences = legacyMediaFile(context), + dateString = MEDIA_DATE_STRING, + uri = MEDIA_URI, + ) + + runBlocking { + migration.migrate(MediaPrefs()) + migration.cleanUp() + } + + val remaining = legacyCommonsFile(context).all.keys + + assertTrue(remaining.none { it in LEGACY_CAPTURE_KEY_NAMES }) + assertTrue(remaining.contains("photo_quality")) + assertFalse(legacyMediaFileExists(context)) + } + + private fun writeCapturedItem( + preferences: SharedPreferences, + dateString: String, + uri: String, + ) { + preferences.edit(commit = true) { + putInt("last_captured_item_type", ITEM_TYPE_IMAGE) + putString("last_captured_item_date_string", dateString) + putString("last_captured_item_uri", uri) + } + } + + private companion object { + const val SOME_PHOTO_QUALITY = 71 + const val COMMONS_DATE_STRING = "20260724_100000_000" + const val COMMONS_URI = "content://media/external/images/media/2" + const val MEDIA_DATE_STRING = "20260724_110000_000" + const val MEDIA_URI = "content://media/external/images/media/3" + const val CURRENT_DATE_STRING = "20260724_120000_000" + const val CURRENT_URI = "content://media/external/images/media/4" + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/media/StoragePrefsMigrationTest.kt b/app/src/test/java/app/grapheneos/camera/data/media/StoragePrefsMigrationTest.kt new file mode 100644 index 00000000..4a4c4450 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/media/StoragePrefsMigrationTest.kt @@ -0,0 +1,135 @@ +package app.grapheneos.camera.data.media + +import android.content.Context +import androidx.core.content.edit +import androidx.test.core.app.ApplicationProvider +import app.grapheneos.camera.CapturedItems +import app.grapheneos.camera.data.core.LEGACY_CAPTURE_KEY_NAMES +import app.grapheneos.camera.data.core.LEGACY_STORAGE_KEY_NAMES +import app.grapheneos.camera.data.core.clearLegacyPreferences +import app.grapheneos.camera.data.core.legacyCommonsFile +import app.grapheneos.camera.data.core.writeLegacyPreferences +import app.grapheneos.camera.data.media.store.StoragePrefs +import app.grapheneos.camera.data.media.store.StoragePrefsMigration +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StoragePrefsMigrationTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val migration = StoragePrefsMigration(context) + + @Before + fun clearLegacyFiles() { + clearLegacyPreferences(context) + } + + @Test + fun shouldMigrate_freshInstall_isFalse() { + assertFalse(runBlocking { migration.shouldMigrate(StoragePrefs()) }) + } + + @Test + fun migrate_whereCapturesAreSaved_isCarriedOverVerbatim() { + val trees = listOf("content://tree/a", "content://tree/b") + legacyCommonsFile(context).edit(commit = true) { + putString(STORAGE_LOCATION, CURRENT_TREE) + putString( + PREVIOUS_SAF_TREES, + trees.joinToString(separator = CapturedItems.SAF_TREE_SEPARATOR), + ) + putString(LEGACY_MEDIA_URIS, LEGACY_URIS) + } + + val migrated = runBlocking { + assertTrue("nothing to migrate", migration.shouldMigrate(StoragePrefs())) + migration.migrate(StoragePrefs()) + } + + assertEquals(CURRENT_TREE, migrated.storageLocation) + assertEquals(trees, migrated.previousSafTrees) + assertEquals(LEGACY_URIS, migrated.legacyMediaUris) + } + + @Test + fun migrate_aDirectoryWasNeverPicked_carriesOverNothing() { + legacyCommonsFile(context).edit(commit = true) { putInt(PHOTO_QUALITY, SOME_PHOTO_QUALITY) } + + val migrated = runBlocking { migration.migrate(StoragePrefs()) } + + assertNull(migrated.storageLocation) + assertEquals(emptyList(), migrated.previousSafTrees) + assertNull(migrated.legacyMediaUris) + } + + @Test + fun migrate_onlyPresentLegacyKeys_replaceCurrentData() { + val current = StoragePrefs( + storageLocation = "content://tree/new", + previousSafTrees = listOf("content://tree/kept"), + legacyMediaUris = "content://media/kept", + ) + legacyCommonsFile(context).edit(commit = true) { putString(STORAGE_LOCATION, CURRENT_TREE) } + + val migrated = runBlocking { migration.migrate(current) } + + assertEquals(CURRENT_TREE, migrated.storageLocation) + assertEquals(current.previousSafTrees, migrated.previousSafTrees) + assertEquals(current.legacyMediaUris, migrated.legacyMediaUris) + } + + @Test + fun shouldMigrate_afterTheTreesHaveBeenCarriedOver_isFalse() { + writeLegacyPreferences(context) + + runBlocking { + migration.migrate(StoragePrefs()) + migration.cleanUp() + } + + assertFalse(runBlocking { migration.shouldMigrate(StoragePrefs()) }) + } + + @Test + fun shouldMigrate_onlyAnotherOwnersKeysArePresent_isFalse() { + legacyCommonsFile(context).edit(commit = true) { putInt(PHOTO_QUALITY, SOME_PHOTO_QUALITY) } + + assertFalse(runBlocking { migration.shouldMigrate(StoragePrefs()) }) + } + + @Test + fun cleanUp_removesOnlyItsOwnKeys() { + writeLegacyPreferences(context) + + runBlocking { + migration.migrate(StoragePrefs()) + migration.cleanUp() + } + + val remaining = legacyCommonsFile(context).all.keys + + assertTrue(remaining.none { it in LEGACY_STORAGE_KEY_NAMES }) + assertTrue(remaining.containsAll(LEGACY_CAPTURE_KEY_NAMES)) + assertTrue(remaining.contains(PHOTO_QUALITY)) + } + + private companion object { + const val STORAGE_LOCATION = "storage_location" + const val PREVIOUS_SAF_TREES = "previous_saf_trees" + const val LEGACY_MEDIA_URIS = "media_uri_s" + const val PHOTO_QUALITY = "photo_quality" + + const val CURRENT_TREE = "content://tree/current" + const val LEGACY_URIS = "content://tree/a/document/photo.jpg" + const val SOME_PHOTO_QUALITY = 71 + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/settings/CameraSettingsMapperTest.kt b/app/src/test/java/app/grapheneos/camera/data/settings/CameraSettingsMapperTest.kt new file mode 100644 index 00000000..afa3d39e --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/settings/CameraSettingsMapperTest.kt @@ -0,0 +1,40 @@ +package app.grapheneos.camera.data.settings + +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapper +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapperImpl +import app.grapheneos.camera.data.settings.model.CameraSettings +import app.grapheneos.camera.data.settings.model.GridType +import app.grapheneos.camera.data.settings.store.StoredCameraSettings +import app.grapheneos.camera.data.settings.store.StoredGridType +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CameraSettingsMapperTest { + + private val mapper: CameraSettingsMapper = CameraSettingsMapperImpl() + + @Test + fun storedValues_mapToTheFeatureModel() { + val stored = StoredCameraSettings( + gridType = StoredGridType.GOLDEN_RATIO, + photoQuality = SOME_PHOTO_QUALITY, + ) + + val settings = mapper.map(stored) + + assertEquals(GridType.GOLDEN_RATIO, settings.gridType) + assertEquals(SOME_PHOTO_QUALITY, settings.photoQuality) + } + + @Test + fun featureDefaults_areAbsentFromStorage() { + assertEquals(StoredCameraSettings(), mapper.map(CameraSettings())) + } + + private companion object { + const val SOME_PHOTO_QUALITY = 71 + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/settings/SettingsPrefsMigrationTest.kt b/app/src/test/java/app/grapheneos/camera/data/settings/SettingsPrefsMigrationTest.kt new file mode 100644 index 00000000..2329ba9c --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/settings/SettingsPrefsMigrationTest.kt @@ -0,0 +1,331 @@ +package app.grapheneos.camera.data.settings + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.test.core.app.ApplicationProvider +import app.grapheneos.camera.data.core.LEGACY_CAPTURE_KEY_NAMES +import app.grapheneos.camera.data.core.LEGACY_STORAGE_KEY_NAMES +import app.grapheneos.camera.data.core.clearLegacyPreferences +import app.grapheneos.camera.data.core.legacyCommonsFile +import app.grapheneos.camera.data.core.legacyCommonsFileExists +import app.grapheneos.camera.data.core.legacyModeFile +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.core.writeLegacyPreferences +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapper +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapperImpl +import app.grapheneos.camera.data.settings.model.CameraSettings +import app.grapheneos.camera.data.settings.model.GridType +import app.grapheneos.camera.data.settings.model.SettingsDefaults +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.data.settings.store.SettingsPrefsMigration +import app.grapheneos.camera.data.settings.store.StoredCameraSettings +import app.grapheneos.camera.data.settings.store.StoredModeSettings +import app.grapheneos.camera.data.settings.store.StoredVideoQuality +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SettingsPrefsMigrationTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val migration = SettingsPrefsMigration(context) + + private val mapper: CameraSettingsMapper = CameraSettingsMapperImpl() + + private fun commons(): SharedPreferences { + return legacyCommonsFile(context) + } + + private fun modePreferences(mode: CameraMode): SharedPreferences { + return legacyModeFile(context, mode) + } + + private fun commonsFileExists(): Boolean { + return legacyCommonsFileExists(context) + } + + private fun migrate(): SettingsPrefs { + return runBlocking { + assertTrue("nothing to migrate", migration.shouldMigrate(SettingsPrefs())) + migration.migrate(SettingsPrefs()) + } + } + + private fun migrateCommon(): CameraSettings { + return mapper.map(migrate().common) + } + + @Before + fun clearLegacyFiles() { + clearLegacyPreferences(context) + } + + @Test + fun shouldMigrate_freshInstall_isFalse() { + assertFalse(runBlocking { migration.shouldMigrate(SettingsPrefs()) }) + } + + @Test + fun shouldMigrate_onlyAModeFileHoldsAnything_isTrue() { + modePreferences(MODE).edit(commit = true) { putBoolean(GEO_TAGGING, true) } + + assertTrue(runBlocking { migration.shouldMigrate(SettingsPrefs()) }) + } + + @Test + fun migrate_gridStoredAsAnOrdinal_becomesTheNamedConstant() { + commons().edit(commit = true) { putInt(GRID, GOLDEN_RATIO_ORDINAL) } + + assertEquals(GridType.GOLDEN_RATIO, migrateCommon().gridType) + } + + @Test + fun migrate_gridOrdinalPastTheEnd_readsAsTheDefault() { + commons().edit(commit = true) { putInt(GRID, GOLDEN_RATIO_ORDINAL + 1) } + + assertEquals(SettingsDefaults.GRID_TYPE, migrateCommon().gridType) + } + + @Test + fun migrate_focusTimeout_becomesSeconds() { + commons().edit(commit = true) { putString(FOCUS_TIMEOUT, "10s") } + + assertEquals(10L, migrateCommon().focusTimeoutSeconds) + + commons().edit(commit = true) { putString(FOCUS_TIMEOUT, "Off") } + + assertEquals(0L, migrateCommon().focusTimeoutSeconds) + } + + @Test + fun migrate_photoQualityOfZero_readsAsTheDefault() { + commons().edit(commit = true) { putInt(PHOTO_QUALITY, 0) } + + assertEquals(SettingsDefaults.PHOTO_QUALITY, migrateCommon().photoQuality) + } + + @Test + fun migrate_legacyQualityEmphasis_becomesTheHighestQuality() { + commons().edit(commit = true) { putBoolean(EMPHASIS_ON_QUALITY, true) } + + assertEquals(MAX_PHOTO_QUALITY, migrateCommon().photoQuality) + } + + @Test + fun migrate_legacySpeedEmphasis_becomesTheDefaultQuality() { + commons().edit(commit = true) { putBoolean(EMPHASIS_ON_QUALITY, false) } + val current = SettingsPrefs( + common = StoredCameraSettings(photoQuality = SOME_PHOTO_QUALITY), + ) + + val migrated = runBlocking { migration.migrate(current) } + + assertEquals(SettingsDefaults.PHOTO_QUALITY, migrated.common.photoQuality) + } + + @Test + fun migrate_qualityEmphasisAlongsideAChosenQuality_keepsTheChosenOne() { + commons().edit(commit = true) { + putBoolean(EMPHASIS_ON_QUALITY, true) + putInt(PHOTO_QUALITY, SOME_PHOTO_QUALITY) + } + + assertEquals(SOME_PHOTO_QUALITY, migrateCommon().photoQuality) + } + + @Test + fun migrate_installPredatingSaveAsPreviewed_keepsRecordingTheOldWay() { + commons().edit(commit = true) { putBoolean(SAVE_IMAGE_AS_PREVIEW, true) } + + val migrated = migrateCommon() + + assertEquals(true, migrated.saveImageAsPreviewed) + assertEquals(false, migrated.saveVideoAsPreviewed) + } + + @Test + fun migrate_settingsNobodyTouched_takeTheDeclaredDefault() { + commons().edit(commit = true) { putInt(PHOTO_QUALITY, SOME_PHOTO_QUALITY) } + + val migrated = migrateCommon() + + assertEquals(SettingsDefaults.SAVE_IMAGE_AS_PREVIEW, migrated.saveImageAsPreviewed) + assertEquals(SettingsDefaults.SAVE_VIDEO_AS_PREVIEW, migrated.saveVideoAsPreviewed) + assertEquals(SettingsDefaults.ENABLE_EIS, migrated.enableEis) + assertEquals(SettingsDefaults.ASPECT_RATIO, migrated.aspectRatio) + } + + @Test + fun migrate_barcodeFormatsNeverOpened_readAsTheDefault() { + commons().edit(commit = true) { putBoolean(SCAN_ALL_CODES, true) } + + assertEquals( + SettingsDefaults.ENABLED_BARCODE_FORMATS, + migrateCommon().enabledBarcodeFormats, + ) + } + + @Test + fun migrate_barcodeFormats_carryOverOnlyTheEnabledOnes() { + commons().edit(commit = true) { + putBoolean(SCAN_ALL_CODES, true) + putBoolean("scan_QR_CODE", true) + putBoolean("scan_AZTEC", true) + putBoolean("scan_CODE_39", false) + } + + assertEquals(setOf("QR_CODE", "AZTEC"), migrateCommon().enabledBarcodeFormats) + } + + @Test + fun migrate_videoQualityStoredAsItsLabel_becomesTheNamedQuality() { + modePreferences(MODE).edit(commit = true) { + putString(VIDEO_QUALITY_BACK, "1080p (FHD)") + putString(VIDEO_QUALITY_FRONT, "720p (HD)") + } + + val migrated = migrate().modes.getValue(MODE.name) + + assertEquals(StoredVideoQuality.FHD, migrated.videoQualityBack) + assertEquals(StoredVideoQuality.HD, migrated.videoQualityFront) + } + + @Test + fun migrate_unrecognisedVideoQualityLabel_keepsReadingAsItAlwaysHas() { + modePreferences(MODE).edit(commit = true) { putString(VIDEO_QUALITY_BACK, "Unknown") } + + assertEquals( + StoredVideoQuality.SD, + migrate().modes.getValue(MODE.name).videoQualityBack, + ) + } + + @Test + fun migrate_perModeSettings_stayWithTheirOwnMode() { + modePreferences(MODE).edit(commit = true) { putBoolean(GEO_TAGGING, true) } + modePreferences(OTHER_MODE).edit(commit = true) { putInt(FLASH_MODE, SOME_FLASH_MODE) } + + val modes = migrate().modes + + assertEquals( + StoredModeSettings(geoTagging = true), + modes.getValue(MODE.name), + ) + assertEquals( + StoredModeSettings(flashMode = SOME_FLASH_MODE), + modes.getValue(OTHER_MODE.name), + ) + } + + @Test + fun migrate_onlyPresentLegacyKeys_replaceCurrentData() { + val futureMode = "A_MODE_FROM_THE_FUTURE" + val current = SettingsPrefs( + common = StoredCameraSettings( + aspectRatio = 1, + photoQuality = 55, + ), + modes = mapOf( + MODE.name to StoredModeSettings(geoTagging = true), + futureMode to StoredModeSettings(flashMode = 2), + ), + ) + commons().edit(commit = true) { putInt(PHOTO_QUALITY, SOME_PHOTO_QUALITY) } + modePreferences(MODE).edit(commit = true) { putInt(FLASH_MODE, SOME_FLASH_MODE) } + + val migrated = runBlocking { migration.migrate(current) } + + assertEquals(1, migrated.common.aspectRatio) + assertEquals(SOME_PHOTO_QUALITY, migrated.common.photoQuality) + assertEquals(true, migrated.modes.getValue(MODE.name).geoTagging) + assertEquals(SOME_FLASH_MODE, migrated.modes.getValue(MODE.name).flashMode) + assertEquals(current.modes.getValue(futureMode), migrated.modes.getValue(futureMode)) + } + + @Test + fun migrate_modeWithNothingStored_isNotCarriedOver() { + modePreferences(MODE).edit(commit = true) { putBoolean(GEO_TAGGING, true) } + + assertEquals(setOf(MODE.name), migrate().modes.keys) + } + + @Test + fun cleanUp_leavesNoLegacyFileBehind() { + commons().edit(commit = true) { putInt(PHOTO_QUALITY, SOME_PHOTO_QUALITY) } + modePreferences(MODE).edit(commit = true) { putBoolean(GEO_TAGGING, true) } + + runBlocking { + migration.migrate(SettingsPrefs()) + migration.cleanUp() + } + + assertFalse(runBlocking { migration.shouldMigrate(SettingsPrefs()) }) + assertTrue(commons().all.isEmpty()) + assertTrue(modePreferences(MODE).all.isEmpty()) + assertFalse(commonsFileExists()) + } + + @Test + fun cleanUp_removesOnlyItsOwnKeys() { + writeLegacyPreferences(context) + + runBlocking { + migration.migrate(SettingsPrefs()) + migration.cleanUp() + } + + assertEquals(LEGACY_CAPTURE_KEY_NAMES + LEGACY_STORAGE_KEY_NAMES, commons().all.keys) + } + + @Test + fun cleanUp_anotherOwnersKeysRemain_keepsTheCommonsFile() { + writeLegacyPreferences(context) + + runBlocking { + migration.migrate(SettingsPrefs()) + migration.cleanUp() + } + + assertTrue(commonsFileExists()) + } + + @Test + fun shouldMigrate_onlyAnotherOwnersKeysArePresent_isFalse() { + commons().edit(commit = true) { + val owned = LEGACY_CAPTURE_KEY_NAMES + LEGACY_STORAGE_KEY_NAMES + + owned.forEach { putString(it, "whatever its owner stores here") } + } + + assertFalse(runBlocking { migration.shouldMigrate(SettingsPrefs()) }) + } + + private companion object { + val MODE = CameraMode.VIDEO + val OTHER_MODE = CameraMode.CAMERA + + const val EMPHASIS_ON_QUALITY = "emphasis_on_quality" + const val FLASH_MODE = "flash_mode" + const val FOCUS_TIMEOUT = "focus_timeout" + const val GEO_TAGGING = "geo_tagging" + const val GRID = "grid" + const val PHOTO_QUALITY = "photo_quality" + const val SAVE_IMAGE_AS_PREVIEW = "save_image_as_preview" + const val SCAN_ALL_CODES = "scan_all_codes" + const val VIDEO_QUALITY_BACK = "video_quality_BACK" + const val VIDEO_QUALITY_FRONT = "video_quality_FRONT" + + const val GOLDEN_RATIO_ORDINAL = 3 + const val MAX_PHOTO_QUALITY = 100 + const val SOME_PHOTO_QUALITY = 71 + const val SOME_FLASH_MODE = 1 + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/settings/SettingsRepositoryTest.kt b/app/src/test/java/app/grapheneos/camera/data/settings/SettingsRepositoryTest.kt new file mode 100644 index 00000000..0fa407d2 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/settings/SettingsRepositoryTest.kt @@ -0,0 +1,412 @@ +package app.grapheneos.camera.data.settings + +import androidx.camera.video.Quality +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.core.store.InMemoryDataStore +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapper +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapperImpl +import app.grapheneos.camera.data.settings.mapper.ModeSettingsMapper +import app.grapheneos.camera.data.settings.mapper.ModeSettingsMapperImpl +import app.grapheneos.camera.data.settings.mapper.StoredVideoQualityMapper +import app.grapheneos.camera.data.settings.mapper.StoredVideoQualityMapperImpl +import app.grapheneos.camera.data.settings.model.CameraSettings +import app.grapheneos.camera.data.settings.model.GridType +import app.grapheneos.camera.data.settings.model.ModeSettings +import app.grapheneos.camera.data.settings.model.SettingsDefaults +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import app.grapheneos.camera.data.settings.repository.SettingsRepositoryImpl +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.data.settings.store.StoredModeSettings +import app.grapheneos.camera.data.settings.store.StoredVideoQuality +import app.grapheneos.camera.data.settings.store.settingsPrefsSerializer +import java.io.File +import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SettingsRepositoryTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val fileExecutor = Executors.newSingleThreadExecutor() + + private val fileScope = CoroutineScope(SupervisorJob() + fileExecutor.asCoroutineDispatcher()) + + private val dataStore: DataStore = InMemoryDataStore(SettingsPrefs()) + + private val modeSettingsMapper = FakeModeSettingsMapper() + + private val storedVideoQualityMapper = FakeStoredVideoQualityMapper() + + private val cameraSettingsMapper: CameraSettingsMapper = CameraSettingsMapperImpl() + + @After + fun stopWriting() { + fileScope.cancel() + fileExecutor.shutdownNow() + } + + private fun repository(from: DataStore = dataStore): SettingsRepository { + return SettingsRepositoryImpl( + dataStore = from, + cameraSettingsMapper = cameraSettingsMapper, + modeSettingsMapper = modeSettingsMapper, + storedVideoQualityMapper = storedVideoQualityMapper, + ) + } + + private fun settingsOf(repository: SettingsRepository): CameraSettings { + return runBlocking { repository.settings.first() } + } + + private fun stored(from: DataStore = dataStore): SettingsPrefs { + return runBlocking { from.data.first() } + } + + @Test + fun write_returnsWhatItStored() { + val repository = repository() + + runBlocking { + repository.update { it.copy(aspectRatio = SOME_ASPECT_RATIO) } + repository.update { it.copy(gridType = GridType.GOLDEN_RATIO) } + } + + val written = runBlocking { + repository.update { it.copy(photoQuality = SOME_PHOTO_QUALITY) } + } + + assertEquals(SOME_ASPECT_RATIO, written.aspectRatio) + assertEquals(GridType.GOLDEN_RATIO, written.gridType) + assertEquals(SOME_PHOTO_QUALITY, written.photoQuality) + } + + @Test + fun write_reachesTheStoreBeforeReturning() { + val repository = repository() + + runBlocking { repository.update { it.copy(photoQuality = SOME_PHOTO_QUALITY) } } + + assertEquals(SOME_PHOTO_QUALITY, stored().common.photoQuality) + } + + @Test + fun write_afterAnotherRepositoryWroteADifferentSetting_keepsBoth() { + val viewfinder = repository() + val settingsScreen = repository() + + runBlocking { settingsScreen.update { it.copy(photoQuality = SOME_PHOTO_QUALITY) } } + + val written = runBlocking { viewfinder.update { it.copy(aspectRatio = SOME_ASPECT_RATIO) } } + + assertEquals(SOME_PHOTO_QUALITY, stored().common.photoQuality) + assertEquals(SOME_ASPECT_RATIO, stored().common.aspectRatio) + assertEquals(SOME_PHOTO_QUALITY, written.photoQuality) + } + + @Test + fun write_transformReadingTheCurrentValue_isAppliedOnceAgainstWhatIsStored() { + val viewfinder = repository() + val settingsScreen = repository() + + runBlocking { settingsScreen.update { it.copy(photoQuality = SOME_PHOTO_QUALITY) } } + + val written = runBlocking { + viewfinder.update { it.copy(photoQuality = it.photoQuality + 1) } + } + + assertEquals(SOME_PHOTO_QUALITY + 1, stored().common.photoQuality) + assertEquals(SOME_PHOTO_QUALITY + 1, written.photoQuality) + } + + @Test + fun write_onlyEverReachesTheStoreItWasGiven() { + val owners = InMemoryDataStore( + SettingsPrefs( + common = cameraSettingsMapper.map( + CameraSettings(photoQuality = SOME_PHOTO_QUALITY), + ), + ), + ) + val session = InMemoryDataStore(stored(from = owners)) + val repository = repository(from = session) + + assertEquals(SOME_PHOTO_QUALITY, settingsOf(repository).photoQuality) + + runBlocking { + repository.update { it.copy(photoQuality = OTHER_PHOTO_QUALITY) } + repository.selectMode(mode = MODE, isFrontFacing = false) + repository.setGeoTagging(true) + } + + assertEquals(OTHER_PHOTO_QUALITY, settingsOf(repository).photoQuality) + assertEquals(SOME_PHOTO_QUALITY, stored(from = owners).common.photoQuality) + assertEquals(emptyMap(), stored(from = owners).modes) + } + + @Test + fun write_returns_leavingTheValueOnDisk() { + val file = File(temporaryFolder.root, "settings_prefs.json") + val repository = repository( + from = DataStoreFactory.create( + serializer = settingsPrefsSerializer, + scope = fileScope, + ) { + file + }, + ) + + runBlocking { repository.update { it.copy(photoQuality = SOME_PHOTO_QUALITY) } } + + val onDisk = runBlocking { settingsPrefsSerializer.readFrom(file.inputStream()) } + + assertEquals(SOME_PHOTO_QUALITY, onDisk.common.photoQuality) + } + + @Test + fun settings_afterAnotherRepositoryWroteTheSameStore_picksUpTheChange() { + val viewfinder = repository() + val settingsScreen = repository() + + assertEquals(SettingsDefaults.PHOTO_QUALITY, settingsOf(viewfinder).photoQuality) + + runBlocking { settingsScreen.update { it.copy(photoQuality = SOME_PHOTO_QUALITY) } } + + assertEquals(SOME_PHOTO_QUALITY, settingsOf(viewfinder).photoQuality) + } + + @Test + fun writeMode_selectingTheSameModeAgain_keepsTheWritesMadeToIt() { + val repository = repository() + + runBlocking { + repository.selectMode(mode = MODE, isFrontFacing = false) + repository.setGeoTagging(true) + repository.selectMode(mode = MODE, isFrontFacing = false) + } + + assertEquals( + StoredModeSettings(geoTagging = true), + modeSettingsMapper.calls.last().stored, + ) + } + + @Test + fun writeMode_noModeSlotted_dropsTheWrite() { + val repository = repository() + + modeSettingsMapper.result = MAPPER_RESULT + + assertNull(runBlocking { repository.setGeoTagging(true) }) + assertNull(runBlocking { repository.setVideoQuality(Quality.UHD) }) + assertEquals(emptyList(), storedVideoQualityMapper.calls) + assertEquals(emptyMap(), stored().modes) + + runBlocking { + repository.selectMode(mode = MODE, isFrontFacing = false) + repository.setGeoTagging(true) + } + + assertEquals(true, stored().modes[MODE.name]?.geoTagging) + } + + @Test + fun selectMode_exposesWhatTheMapperMadeOfTheStoredMode() { + val repository = repository() + + modeSettingsMapper.result = MAPPER_RESULT + + val selected = runBlocking { repository.selectMode(mode = MODE, isFrontFacing = true) } + + assertEquals( + FakeModeSettingsMapper.Call(stored = StoredModeSettings(), isFrontFacing = true), + modeSettingsMapper.calls.single(), + ) + assertEquals(MAPPER_RESULT, selected) + } + + @Test + fun setVideoQuality_storesTheNameTheMapperGaveIt() { + val repository = repository() + + runBlocking { repository.selectMode(mode = MODE, isFrontFacing = false) } + storedVideoQualityMapper.result = StoredVideoQuality.DEVICE_CHOICE + runBlocking { repository.setVideoQuality(Quality.HIGHEST) } + + assertEquals(listOf(Quality.HIGHEST), storedVideoQualityMapper.calls) + assertEquals( + StoredVideoQuality.DEVICE_CHOICE, + stored().modes[MODE.name]?.videoQualityBack, + ) + } + + @Test + fun writeMode_qualityTheStoreCannotName_isStillWhatTheModeReports() { + val repository = SettingsRepositoryImpl( + dataStore = dataStore, + cameraSettingsMapper = cameraSettingsMapper, + modeSettingsMapper = ModeSettingsMapperImpl(), + storedVideoQualityMapper = StoredVideoQualityMapperImpl(), + ) + + runBlocking { repository.selectMode(mode = MODE, isFrontFacing = false) } + + val written = runBlocking { repository.setVideoQuality(Quality.LOWEST) } + + assertEquals(Quality.LOWEST, written?.videoQuality) + assertEquals( + StoredVideoQuality.DEVICE_CHOICE, + stored().modes[MODE.name]?.videoQualityBack, + ) + } + + @Test + fun setVideoQuality_eachLensFacing_isStoredSeparately() { + val repository = repository() + + runBlocking { repository.selectMode(mode = MODE, isFrontFacing = false) } + storedVideoQualityMapper.result = StoredVideoQuality.UHD + runBlocking { repository.setVideoQuality(Quality.UHD) } + + runBlocking { repository.selectMode(mode = MODE, isFrontFacing = true) } + storedVideoQualityMapper.result = StoredVideoQuality.HD + runBlocking { repository.setVideoQuality(Quality.HD) } + + val storedMode = stored().modes.getValue(MODE.name) + + assertEquals(StoredVideoQuality.UHD, storedMode.videoQualityBack) + assertEquals(StoredVideoQuality.HD, storedMode.videoQualityFront) + } + + @Test + fun selectMode_afterARelaunch_mapsWhatTheStoreHeld() { + val repository = repository() + + runBlocking { repository.selectMode(mode = MODE, isFrontFacing = false) } + storedVideoQualityMapper.result = StoredVideoQuality.FHD + runBlocking { repository.setVideoQuality(Quality.FHD) } + + val relaunched = repository() + + runBlocking { relaunched.selectMode(mode = MODE, isFrontFacing = false) } + + assertEquals( + StoredModeSettings(videoQualityBack = StoredVideoQuality.FHD), + modeSettingsMapper.calls.last().stored, + ) + } + + @Test + fun writeMode_eachMode_isStoredSeparately() { + val repository = repository() + + runBlocking { + repository.selectMode(mode = MODE, isFrontFacing = false) + repository.setGeoTagging(true) + repository.selectMode(mode = OTHER_MODE, isFrontFacing = false) + } + + assertEquals(StoredModeSettings(), modeSettingsMapper.calls.last().stored) + + runBlocking { repository.selectMode(mode = MODE, isFrontFacing = false) } + + assertEquals(StoredModeSettings(geoTagging = true), modeSettingsMapper.calls.last().stored) + } + + @Test + fun barcodeFormats_untouched_defaultToQrCodeOnly() { + assertEquals(setOf(QR_CODE_FORMAT), settingsOf(repository()).enabledBarcodeFormats) + } + + @Test + fun barcodeFormats_disabled_staysDisabledAcrossRelaunch() { + val repository = repository() + + runBlocking { + repository.update { + it.withBarcodeFormat(formatName = QR_CODE_FORMAT, enabled = false) + } + } + + assertFalse(QR_CODE_FORMAT in settingsOf(repository).enabledBarcodeFormats) + assertFalse(QR_CODE_FORMAT in settingsOf(repository()).enabledBarcodeFormats) + } + + @Test + fun barcodeFormats_anotherEnabled_keepsTheDefaultEnabledToo() { + val repository = repository() + + runBlocking { + repository.update { + it.withBarcodeFormat(formatName = AZTEC_FORMAT, enabled = true) + } + } + + assertEquals( + setOf(QR_CODE_FORMAT, AZTEC_FORMAT), + settingsOf(repository()).enabledBarcodeFormats, + ) + } + + private class FakeModeSettingsMapper : ModeSettingsMapper { + + val calls = mutableListOf() + + var result = ModeSettings() + + override fun map(stored: StoredModeSettings, isFrontFacing: Boolean): ModeSettings { + calls += Call(stored = stored, isFrontFacing = isFrontFacing) + + return result + } + + data class Call( + val stored: StoredModeSettings, + val isFrontFacing: Boolean, + ) + } + + private class FakeStoredVideoQualityMapper : StoredVideoQualityMapper { + + val calls = mutableListOf() + + var result = StoredVideoQuality.DEVICE_CHOICE + + override fun map(quality: Quality): StoredVideoQuality { + calls += quality + + return result + } + } + + private companion object { + val MODE = CameraMode.VIDEO + val OTHER_MODE = CameraMode.CAMERA + + val MAPPER_RESULT = ModeSettings(geoTagging = true, videoQuality = Quality.FHD) + + const val QR_CODE_FORMAT = "QR_CODE" + const val AZTEC_FORMAT = "AZTEC" + + const val SOME_ASPECT_RATIO = 1 + const val SOME_PHOTO_QUALITY = 71 + const val OTHER_PHOTO_QUALITY = 42 + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/settings/SettingsWireFormatTest.kt b/app/src/test/java/app/grapheneos/camera/data/settings/SettingsWireFormatTest.kt new file mode 100644 index 00000000..acbad457 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/settings/SettingsWireFormatTest.kt @@ -0,0 +1,196 @@ +package app.grapheneos.camera.data.settings + +import androidx.camera.core.AspectRatio +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapper +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapperImpl +import app.grapheneos.camera.data.settings.model.CameraSettings +import app.grapheneos.camera.data.settings.model.GridType +import app.grapheneos.camera.data.settings.model.SettingsDefaults +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.data.settings.store.StoredModeSettings +import app.grapheneos.camera.data.settings.store.StoredVideoQuality +import app.grapheneos.camera.data.settings.store.settingsPrefsSerializer +import java.io.ByteArrayOutputStream +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SettingsWireFormatTest { + + private val mapper: CameraSettingsMapper = CameraSettingsMapperImpl() + + private val everySettingChosen = CameraSettings( + aspectRatio = AspectRatio.RATIO_16_9, + gridType = GridType.GOLDEN_RATIO, + focusTimeoutSeconds = SOME_FOCUS_TIMEOUT_SECONDS, + selfTimerDurationSeconds = SOME_SELF_TIMER_DURATION, + enableCameraSounds = false, + includeAudio = false, + enableEis = false, + enableZsl = true, + waitForFocusLock = true, + selectHighestResolution = true, + photoQuality = SOME_PHOTO_QUALITY, + removeExifAfterCapture = false, + gyroscopeSuggestions = true, + saveImageAsPreviewed = false, + saveVideoAsPreviewed = false, + scanAllCodes = true, + enabledBarcodeFormats = setOf(SOME_BARCODE_FORMAT), + ) + + private fun encode(settings: CameraSettings): String { + val output = ByteArrayOutputStream() + + runBlocking { + settingsPrefsSerializer.writeTo( + SettingsPrefs(common = mapper.map(settings)), + output, + ) + } + + return output.toByteArray().decodeToString() + } + + private fun decode(stored: String): CameraSettings { + return runBlocking { + settingsPrefsSerializer.readFrom(stored.encodeToByteArray().inputStream()) + }.common.let(mapper::map) + } + + @Test + fun everySetting_chosen_isStoredUnderTheKeyItHasAlwaysHad() { + assertEquals(EVERY_SETTING_ON_DISK, encode(everySettingChosen)) + } + + @Test + fun everySetting_stored_readsBackAsWhatWasChosen() { + assertEquals(everySettingChosen, decode(EVERY_SETTING_ON_DISK)) + } + + @Test + fun nothingChosen_isNotWrittenAtAll() { + assertEquals("{}", encode(CameraSettings())) + } + + @Test + fun decode_aKeyFromALaterVersion_isSkipped() { + val stored = """{"common":{"photo_quality":71,"a_setting_from_the_future":true}}""" + + assertEquals(SOME_PHOTO_QUALITY, decode(stored).photoQuality) + } + + @Test + fun gridType_everyConstant_roundTripsThroughItsStoredName() { + GridType.entries.forEach { grid -> + assertEquals(grid, decode(encode(CameraSettings(gridType = grid))).gridType) + } + } + + @Test + fun gridType_aNameThisVersionNoLongerHas_readsAsTheDefault() { + val stored = """{"common":{"grid_type":"SPIRAL_OF_THEODORUS","photo_quality":71}}""" + + val settings = decode(stored) + + assertEquals(SettingsDefaults.GRID_TYPE, settings.gridType) + assertEquals(SOME_PHOTO_QUALITY, settings.photoQuality) + } + + private fun encodeMode(mode: StoredModeSettings): String { + val output = ByteArrayOutputStream() + + runBlocking { + settingsPrefsSerializer.writeTo(SettingsPrefs(modes = mapOf(MODE to mode)), output) + } + + return output.toByteArray().decodeToString() + } + + private fun decodeMode(stored: String): StoredModeSettings { + return runBlocking { + settingsPrefsSerializer.readFrom(stored.encodeToByteArray().inputStream()) + }.modes.getValue(MODE) + } + + @Test + fun everyModeSetting_chosen_isStoredUnderTheKeyItHasAlwaysHad() { + val mode = StoredModeSettings( + flashMode = SOME_FLASH_MODE, + geoTagging = true, + selfIllumination = true, + videoQualityFront = StoredVideoQuality.HD, + videoQualityBack = StoredVideoQuality.UHD, + ) + + assertEquals(EVERY_MODE_SETTING_ON_DISK, encodeMode(mode)) + } + + @Test + fun videoQuality_everyConstant_roundTripsThroughItsStoredName() { + StoredVideoQuality.entries.forEach { quality -> + val stored = encodeMode(StoredModeSettings(videoQualityBack = quality)) + + assertEquals(quality, decodeMode(stored).videoQualityBack) + } + } + + @Test + fun videoQuality_leftToTheDevice_isNotWrittenAtAll() { + assertEquals("""{"modes":{"VIDEO":{}}}""", encodeMode(StoredModeSettings())) + } + + @Test + fun videoQuality_aNameThisVersionNoLongerHas_readsAsLeftToTheDevice() { + val stored = """{"modes":{"VIDEO":{"flash_mode":2,"video_quality_back":"EIGHT_K"}}}""" + + val mode = decodeMode(stored) + + assertEquals(StoredVideoQuality.DEVICE_CHOICE, mode.videoQualityBack) + assertEquals(SOME_FLASH_MODE, mode.flashMode) + } + + @Test + fun modeNames_areStableWireKeys() { + assertEquals( + mapOf( + CameraMode.QR_SCAN to "QR_SCAN", + CameraMode.AUTO to "AUTO", + CameraMode.FACE_RETOUCH to "FACE_RETOUCH", + CameraMode.PORTRAIT to "PORTRAIT", + CameraMode.NIGHT to "NIGHT", + CameraMode.HDR to "HDR", + CameraMode.CAMERA to "CAMERA", + CameraMode.VIDEO to "VIDEO", + ), + CameraMode.entries.associateWith(SettingsPrefs::storedModeName), + ) + } + + private companion object { + const val MODE = "VIDEO" + const val SOME_FLASH_MODE = 2 + const val SOME_FOCUS_TIMEOUT_SECONDS = 10L + const val SOME_SELF_TIMER_DURATION = 3 + const val SOME_PHOTO_QUALITY = 71 + const val SOME_BARCODE_FORMAT = "AZTEC" + + const val EVERY_SETTING_ON_DISK = """{"common":{"aspect_ratio":1,""" + + """"grid_type":"GOLDEN_RATIO","focus_timeout_seconds":10,""" + + """"self_timer_duration_seconds":3,"enable_camera_sounds":false,""" + + """"include_audio":false,"enable_eis":false,"enable_zsl":true,""" + + """"wait_for_focus_lock":true,"select_highest_resolution":true,""" + + """"photo_quality":71,"remove_exif_after_capture":false,""" + + """"gyroscope_suggestions":true,"save_image_as_previewed":false,""" + + """"save_video_as_previewed":false,"scan_all_codes":true,""" + + """"enabled_barcode_formats":["AZTEC"]}}""" + + const val EVERY_MODE_SETTING_ON_DISK = """{"modes":{"VIDEO":{"flash_mode":2,""" + + """"geo_tagging":true,"self_illumination":true,""" + + """"video_quality_front":"HD","video_quality_back":"UHD"}}}""" + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/settings/VideoQualityTitleTest.kt b/app/src/test/java/app/grapheneos/camera/data/settings/VideoQualityTitleTest.kt new file mode 100644 index 00000000..a12a689b --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/settings/VideoQualityTitleTest.kt @@ -0,0 +1,41 @@ +package app.grapheneos.camera.data.settings + +import androidx.camera.video.Quality +import app.grapheneos.camera.data.settings.model.videoQualityFromTitle +import app.grapheneos.camera.data.settings.model.videoQualityTitle +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VideoQualityTitleTest { + + @Test + fun videoQualityFromTitle_everyOfferedQuality_roundTripsThroughItsTitle() { + val qualities = listOf( + Quality.UHD, + Quality.FHD, + Quality.HD, + Quality.SD, + ) + + qualities.forEach { quality -> + assertEquals(quality, videoQualityFromTitle(videoQualityTitle(quality))) + } + } + + @Test + fun videoQualityTitle_offeredQualities_keepTheirWording() { + assertEquals("2160p (UHD)", videoQualityTitle(Quality.UHD)) + assertEquals("1080p (FHD)", videoQualityTitle(Quality.FHD)) + assertEquals("720p (HD)", videoQualityTitle(Quality.HD)) + assertEquals("480p (SD)", videoQualityTitle(Quality.SD)) + } + + @Test + fun videoQualityFromTitle_unrecognizedTitle_fallsBackRatherThanThrowing() { + assertEquals(Quality.SD, videoQualityFromTitle("4320p (8K)")) + assertEquals(Quality.SD, videoQualityFromTitle("")) + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/settings/mapper/ModeSettingsMapperImplTest.kt b/app/src/test/java/app/grapheneos/camera/data/settings/mapper/ModeSettingsMapperImplTest.kt new file mode 100644 index 00000000..e783daea --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/settings/mapper/ModeSettingsMapperImplTest.kt @@ -0,0 +1,81 @@ +package app.grapheneos.camera.data.settings.mapper + +import androidx.camera.video.Quality +import app.grapheneos.camera.data.settings.model.ModeSettings +import app.grapheneos.camera.data.settings.model.SettingsDefaults +import app.grapheneos.camera.data.settings.store.StoredModeSettings +import app.grapheneos.camera.data.settings.store.StoredVideoQuality +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ModeSettingsMapperImplTest { + + private val mapper = ModeSettingsMapperImpl() + + @Test + fun map_settingsNeverConfigured_readAsTheDeclaredDefaults() { + val mapped = mapper.map(stored = StoredModeSettings(), isFrontFacing = false) + + assertEquals(SettingsDefaults.FLASH_MODE, mapped.flashMode) + assertEquals(SettingsDefaults.GEO_TAGGING, mapped.geoTagging) + assertEquals(SettingsDefaults.SELF_ILLUMINATION, mapped.selfIllumination) + assertEquals(SettingsDefaults.VIDEO_QUALITY, mapped.videoQuality) + } + + @Test + fun map_everySettingConfigured_isCarriedOver() { + val stored = StoredModeSettings( + flashMode = SOME_FLASH_MODE, + geoTagging = true, + selfIllumination = true, + videoQualityBack = StoredVideoQuality.FHD, + ) + + assertEquals( + ModeSettings( + flashMode = SOME_FLASH_MODE, + geoTagging = true, + selfIllumination = true, + videoQuality = Quality.FHD, + ), + mapper.map(stored = stored, isFrontFacing = false), + ) + } + + @Test + fun map_theSlottedFacing_isTheOnlyVideoQualityExposed() { + val stored = StoredModeSettings( + videoQualityFront = StoredVideoQuality.HD, + videoQualityBack = StoredVideoQuality.UHD, + ) + + assertEquals(Quality.HD, mapper.map(stored = stored, isFrontFacing = true).videoQuality) + assertEquals(Quality.UHD, mapper.map(stored = stored, isFrontFacing = false).videoQuality) + } + + @Test + fun map_aStoredVideoQuality_readsAsTheResolutionItNames() { + assertEquals(Quality.UHD, mapped(StoredVideoQuality.UHD)) + assertEquals(Quality.FHD, mapped(StoredVideoQuality.FHD)) + assertEquals(Quality.HD, mapped(StoredVideoQuality.HD)) + assertEquals(Quality.SD, mapped(StoredVideoQuality.SD)) + } + + @Test + fun map_aVideoQualityLeftToTheDevice_readsAsTheDefault() { + assertEquals(SettingsDefaults.VIDEO_QUALITY, mapped(StoredVideoQuality.DEVICE_CHOICE)) + } + + private fun mapped(quality: StoredVideoQuality): Quality { + val stored = StoredModeSettings(videoQualityBack = quality) + + return mapper.map(stored = stored, isFrontFacing = false).videoQuality + } + + private companion object { + const val SOME_FLASH_MODE = 2 + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/settings/mapper/StoredVideoQualityMapperImplTest.kt b/app/src/test/java/app/grapheneos/camera/data/settings/mapper/StoredVideoQualityMapperImplTest.kt new file mode 100644 index 00000000..fa8da369 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/settings/mapper/StoredVideoQualityMapperImplTest.kt @@ -0,0 +1,52 @@ +package app.grapheneos.camera.data.settings.mapper + +import androidx.camera.video.Quality +import app.grapheneos.camera.data.settings.store.StoredModeSettings +import app.grapheneos.camera.data.settings.store.StoredVideoQuality +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StoredVideoQualityMapperImplTest { + + private val mapper = StoredVideoQualityMapperImpl() + + @Test + fun map_aQualityNamingAResolution_isStoredUnderThatName() { + assertEquals(StoredVideoQuality.UHD, mapper.map(Quality.UHD)) + assertEquals(StoredVideoQuality.FHD, mapper.map(Quality.FHD)) + assertEquals(StoredVideoQuality.HD, mapper.map(Quality.HD)) + assertEquals(StoredVideoQuality.SD, mapper.map(Quality.SD)) + } + + @Test + fun map_aQualityNamingNoResolution_isLeftToTheDevice() { + assertEquals(StoredVideoQuality.DEVICE_CHOICE, mapper.map(Quality.HIGHEST)) + assertEquals(StoredVideoQuality.DEVICE_CHOICE, mapper.map(Quality.LOWEST)) + } + + @Test + fun map_everyOfferedQuality_readsBackAsItself() { + val readBack = ModeSettingsMapperImpl() + + OFFERED_QUALITIES.forEach { quality -> + val stored = StoredModeSettings(videoQualityBack = mapper.map(quality)) + + assertEquals( + quality, + readBack.map(stored = stored, isFrontFacing = false).videoQuality, + ) + } + } + + private companion object { + val OFFERED_QUALITIES = listOf( + Quality.UHD, + Quality.FHD, + Quality.HD, + Quality.SD, + ) + } +} diff --git a/app/src/test/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModuleTest.kt b/app/src/test/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModuleTest.kt new file mode 100644 index 00000000..109c02c9 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModuleTest.kt @@ -0,0 +1,130 @@ +package app.grapheneos.camera.di.preferences + +import android.app.Activity +import androidx.datastore.core.DataStore +import app.grapheneos.camera.data.core.store.InMemoryDataStore +import app.grapheneos.camera.data.media.store.StoragePrefs +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.data.settings.store.StoredCameraSettings +import app.grapheneos.camera.ui.activities.MoreSettings +import app.grapheneos.camera.ui.activities.MoreSettingsSecure +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PreferencesProvidesModuleTest { + + private val module = PreferencesProvidesModule() + + private val durableSettings: DataStore = InMemoryDataStore( + SettingsPrefs(common = StoredCameraSettings(photoQuality = OWNERS_PHOTO_QUALITY)), + ) + + private val durableStorage: DataStore = InMemoryDataStore(StoragePrefs()) + + private fun settingsPrefsFor(type: Class): DataStore { + return module.provideSettingsPrefs( + context = Robolectric.buildActivity(type).get(), + durable = durableSettings, + ) + } + + private fun storagePrefsFor(type: Class): DataStore { + return module.provideStoragePrefs( + context = Robolectric.buildActivity(type).get(), + durable = durableStorage, + ) + } + + private fun stored(from: DataStore): T { + return runBlocking { from.data.first() } + } + + @Test + fun settingsPrefs_secureActivity_keepsWritesOutOfTheOwnersStore() { + val session = settingsPrefsFor(MoreSettingsSecure::class.java) + + runBlocking { + session.updateData { + it.copy(common = it.common.copy(photoQuality = SESSIONS_PHOTO_QUALITY)) + } + } + + assertEquals(SESSIONS_PHOTO_QUALITY, stored(session).common.photoQuality) + assertEquals(OWNERS_PHOTO_QUALITY, stored(durableSettings).common.photoQuality) + } + + @Test + fun settingsPrefs_secureActivity_startsFromWhatTheOwnerConfigured() { + val session = settingsPrefsFor(MoreSettingsSecure::class.java) + + assertEquals(OWNERS_PHOTO_QUALITY, stored(session).common.photoQuality) + } + + @Test + fun settingsPrefs_secureActivity_doesNotFollowTheOwnersLaterChanges() { + val session = settingsPrefsFor(MoreSettingsSecure::class.java) + + runBlocking { + durableSettings.updateData { + it.copy(common = it.common.copy(photoQuality = SESSIONS_PHOTO_QUALITY)) + } + } + + assertEquals(OWNERS_PHOTO_QUALITY, stored(session).common.photoQuality) + } + + @Test + fun settingsPrefs_regularActivity_writesTheOwnersStore() { + val session = settingsPrefsFor(MoreSettings::class.java) + + runBlocking { + session.updateData { + it.copy(common = it.common.copy(photoQuality = SESSIONS_PHOTO_QUALITY)) + } + } + + assertEquals(SESSIONS_PHOTO_QUALITY, stored(durableSettings).common.photoQuality) + } + + @Test + fun storagePrefs_secureActivity_keepsWritesOutOfTheOwnersStore() { + val session = storagePrefsFor(MoreSettingsSecure::class.java) + + runBlocking { session.updateData { it.copy(storageLocation = SESSIONS_LOCATION) } } + + assertEquals(SESSIONS_LOCATION, stored(session).storageLocation) + assertNull(stored(durableStorage).storageLocation) + } + + @Test + fun storagePrefs_secureActivity_doesNotFollowTheOwnersLaterChanges() { + val session = storagePrefsFor(MoreSettingsSecure::class.java) + + runBlocking { durableStorage.updateData { it.copy(storageLocation = OWNERS_LOCATION) } } + + assertNull(stored(session).storageLocation) + } + + @Test + fun storagePrefs_regularActivity_writesTheOwnersStore() { + val session = storagePrefsFor(MoreSettings::class.java) + + runBlocking { session.updateData { it.copy(storageLocation = OWNERS_LOCATION) } } + + assertEquals(OWNERS_LOCATION, stored(durableStorage).storageLocation) + } + + private companion object { + const val OWNERS_PHOTO_QUALITY = 71 + const val SESSIONS_PHOTO_QUALITY = 42 + const val OWNERS_LOCATION = "content://tree/owners" + const val SESSIONS_LOCATION = "content://tree/sessions" + } +} diff --git a/app/src/test/java/app/grapheneos/camera/util/EphemeralSharedPrefsTest.kt b/app/src/test/java/app/grapheneos/camera/util/EphemeralSharedPrefsTest.kt deleted file mode 100644 index a3c4281f..00000000 --- a/app/src/test/java/app/grapheneos/camera/util/EphemeralSharedPrefsTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -package app.grapheneos.camera.util - -import android.content.Context -import android.content.SharedPreferences -import androidx.test.core.app.ApplicationProvider -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -/** - * [EphemeralSharedPrefs] is what stops a lockscreen session from changing the settings the - * owner sees after unlocking: SecureMainActivity and SecureCaptureActivity override - * getSharedPreferences() to hand out one of these, cloned from the real preferences but - * backed by memory, and CamConfig deliberately reads its preferences through the activity so - * it inherits that. - * - * The clone being one-way is the entire security property, and nothing asserted it. - */ -@RunWith(RobolectricTestRunner::class) -class EphemeralSharedPrefsTest { - private val context: Context = ApplicationProvider.getApplicationContext() - - private fun persistentPrefs(): SharedPreferences { - return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - } - - private fun ephemeralPrefs(cloneOriginal: Boolean = true): SharedPreferences { - return EphemeralSharedPrefsNamespace() - .getPrefs(context, PREFS_NAME, Context.MODE_PRIVATE, cloneOriginal = cloneOriginal) - } - - @Before - fun resetPersistentPrefs() { - persistentPrefs().edit().clear().commit() - } - - @Test - fun clonesExistingValuesFromThePersistentPrefs() { - persistentPrefs().edit().putInt("photoQuality", 85).commit() - - assertEquals(85, ephemeralPrefs().getInt("photoQuality", -1)) - } - - @Test - fun writesNeverReachThePersistentPrefs() { - persistentPrefs().edit().putInt("photoQuality", 85).commit() - - val ephemeral = ephemeralPrefs() - ephemeral.edit().putInt("photoQuality", 20).commit() - - assertEquals(20, ephemeral.getInt("photoQuality", -1)) - assertEquals(85, persistentPrefs().getInt("photoQuality", -1)) - } - - @Test - fun removalsNeverReachThePersistentPrefs() { - persistentPrefs().edit().putBoolean("includeAudio", true).commit() - - val ephemeral = ephemeralPrefs() - ephemeral.edit().remove("includeAudio").commit() - - assertFalse(ephemeral.contains("includeAudio")) - assertTrue(persistentPrefs().contains("includeAudio")) - } - - @Test - fun clearNeverReachesThePersistentPrefs() { - persistentPrefs().edit().putBoolean("includeAudio", true).commit() - - val ephemeral = ephemeralPrefs() - ephemeral.edit().clear().commit() - - assertFalse(ephemeral.contains("includeAudio")) - assertTrue(persistentPrefs().contains("includeAudio")) - } - - @Test - fun aRepeatedLookupKeepsTheSessionsChanges() { - persistentPrefs().edit().putInt("photoQuality", 85).commit() - val namespace = EphemeralSharedPrefsNamespace() - - val first = namespace - .getPrefs(context, PREFS_NAME, Context.MODE_PRIVATE, cloneOriginal = true) - first.edit().putInt("photoQuality", 42).commit() - val second = namespace - .getPrefs(context, PREFS_NAME, Context.MODE_PRIVATE, cloneOriginal = true) - - // A second lookup that re-cloned from disk would silently discard everything the - // session changed and hand back the persistent value instead. - assertEquals(42, second.getInt("photoQuality", -1)) - } - - @Test - fun startsEmptyWhenNotCloning() { - persistentPrefs().edit().putInt("photoQuality", 85).commit() - - assertFalse(ephemeralPrefs(cloneOriginal = false).contains("photoQuality")) - } - - @Test - fun rejectsAnyModeOtherThanPrivate() { - val failure = runCatching { - EphemeralSharedPrefsNamespace() - .getPrefs(context, PREFS_NAME, Context.MODE_APPEND, cloneOriginal = true) - }.exceptionOrNull() - - assertTrue( - "Only MODE_PRIVATE is supported, and anything else must fail loudly rather than" + - " return preferences with the wrong semantics, but got $failure", - failure is IllegalArgumentException, - ) - } - - private companion object { - // CamConfig.COMMON_SHARED_PREFS_NAME - const val PREFS_NAME = "commons" - } -} diff --git a/build.gradle.kts b/build.gradle.kts index 071888d3..0ccb29be 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,11 +2,14 @@ import org.jlleitschuh.gradle.ktlint.KtlintExtension plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false alias(libs.plugins.ktlint) } buildscript { dependencies { + classpath(libs.hilt.gradle.plugin) classpath(libs.kotlin.gradle.plugin) classpath(libs.ksp.gradle.plugin) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ab11d27..74043847 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] agp = "9.3.1" +hilt = "2.60.1" kotlin = "2.4.10" ksp = "2.3.10" @@ -11,6 +12,11 @@ appcompat = "1.7.1" camerax = { strictly = "1.6.1" } constraintlayout = "2.2.2" coreKtx = "1.19.0" +datastore = "1.2.1" +# Already reached the compile classpath transitively through CameraX. Declared here because the +# settings layer imports Flow directly, and a transitive dependency is not one to import from. +kotlinxCoroutines = "1.11.0" +kotlinxSerialization = "1.11.0" material = "1.14.0" zxing = "3.5.4" @@ -25,6 +31,11 @@ robolectric = "4.16.1" androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } material = { module = "com.google.android.material:material", version.ref = "material" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } @@ -42,6 +53,7 @@ androidx-test-runner = { module = "androidx.test:runner", version.ref = "android junit4 = { module = "junit:junit", version.ref = "junit4" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +hilt-gradle-plugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } ksp-gradle-plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } @@ -58,4 +70,7 @@ camerax = [ [plugins] android-application = { id = "com.android.application", version.ref = "agp" } detekt = { id = "dev.detekt", version.ref = "detekt" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index dc7f8807..f57e0b02 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -127,6 +127,20 @@ + + + + + + + + + + + + + + @@ -141,6 +155,20 @@ + + + + + + + + + + + + + + @@ -955,6 +983,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1076,6 +1188,17 @@ + + + + + + + + + + + @@ -3234,13 +3357,126 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3297,6 +3533,23 @@ + + + + + + + + + + + + + + + + + @@ -3420,6 +3673,17 @@ + + + + + + + + + + + @@ -3506,6 +3770,20 @@ + + + + + + + + + + + + + + @@ -3526,6 +3804,11 @@ + + + + + @@ -3566,6 +3849,11 @@ + + + + + @@ -3580,6 +3868,25 @@ + + + + + + + + + + + + + + + + + + + @@ -3683,6 +3990,20 @@ + + + + + + + + + + + + + + @@ -3748,6 +4069,23 @@ + + + + + + + + + + + + + + + + + @@ -3778,6 +4116,11 @@ + + + + + @@ -3803,6 +4146,11 @@ + + + + + @@ -3861,6 +4209,20 @@ + + + + + + + + + + + + + + @@ -4520,6 +4882,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6001,6 +6411,20 @@ + + + + + + + + + + + + + + @@ -6443,6 +6867,20 @@ + + + + + + + + + + + + + + @@ -6488,6 +6926,20 @@ + + + + + + + + + + + + + + @@ -6907,6 +7359,11 @@ + + + + + @@ -7644,6 +8101,20 @@ + + + + + + + + + + + + + + @@ -7781,6 +8252,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7803,6 +8353,17 @@ + + + + + + + + + + + @@ -7953,6 +8514,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7984,6 +8576,20 @@ + + + + + + + + + + + + + + @@ -8011,6 +8617,9 @@ + + + @@ -8382,6 +8991,17 @@ + + + + + + + + + + + @@ -8571,6 +9191,11 @@ + + + + + @@ -8676,6 +9301,23 @@ + + + + + + + + + + + + + + + + + @@ -8719,9 +9361,6 @@ - - - @@ -8729,6 +9368,16 @@ + + + + + + + + + + @@ -8759,6 +9408,17 @@ + + + + + + + + + + + @@ -8822,6 +9482,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8920,6 +9611,11 @@ + + + + + @@ -8942,6 +9638,17 @@ + + + + + + + + + + + @@ -8998,6 +9705,17 @@ + + + + + + + + + + + From 3196a15d0844dcea7b8e8ab09772ab7f2f9bc479 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 3 Sep 2026 13:36:06 +0300 Subject: [PATCH 10/14] Document the store role and the preferences carve-out --- AGENTS.md | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1dfd8d9f..a528bda0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,18 +38,20 @@ Each layer splits per feature, and each feature splits by role: ``` app/src/main/java/app/grapheneos/camera/ data/ + core/ + model/ types more than one feature stores, e.g. CameraMode + store/ the preferences files themselves, and the keys features share settings/ model/ CameraSettings, per-mode setting values repository/ SettingsRepository (entry-mode-scoped, never application-scoped) - store/ prefs-backed stores, EphemeralSharedPrefs namespace camera/ model/ CameraCapabilities, lens/extension descriptors repository/ CameraProviderSource store/ ExtensionAvailabilityStore media/ model/ CapturedItem and friends - repository/ CapturedItemStore - store/ MediaStoreDataSource, SafDataSource + repository/ CapturedItemRepository + store/ CapturedItemStore, MediaStoreDataSource, SafDataSource location/ repository/ LocationRepository domain/ @@ -180,6 +182,14 @@ Roles: - **Repository** (`data//repository/`): the feature's public data API. Exposes `Flow`s and `suspend` functions; applies `flowOn(dispatcher)` itself so callers never think about threads. +- **Store** (`data//store/`): the only thing that knows a storage mechanism — + `SharedPreferences`, MediaStore, SAF, a file. It opens that storage itself, and takes and returns + the feature's own types: keys, encodings and file names never leave it. Nothing above the + repository may touch storage directly, and a repository never hands a store out. + **A store is warranted only when it separates something**: a second storage mechanism, or a + repository that already does non-storage work. Where a feature has one mechanism and the + repository does nothing but forward to it, the repository *is* that boundary — a store there is a + second name for the same object and every method on it is a proxy. - **Use case** (`domain//usecase/`): one verb per class, named as the verb (`ShareCapturedItem`), interface exposing `suspend operator fun invoke(...)`. Returns a sealed result type from `domain//model/`, not exceptions. @@ -188,6 +198,17 @@ Roles: `di/core/`, never referenced as `Dispatchers.IO` inline. - **DI** (`di//`): one `@Module @InstallIn(SingletonComponent::class)` abstract class per feature with `@Binds @Reusable` for each interface→Impl pair. Everything is `internal`. + **Preferences are the exception, and they are opened and chosen in two different places.** The + owner's storage is opened once, in a `SingletonComponent` module under its own qualifier — a + process may hold one handle per file, so a second one is not an option to have. Which of them a + session is given is a `@Provides` in an `ActivityComponent` module, built on `@ActivityContext` + and `@ActivityScoped` rather than `@Reusable`: it selects between the owner's storage and a + throwaway copy of it, from the entry point it was given, and nowhere else — a session that has to + ask twice can be handed a second copy, and everything it changed in the first is lost. Nothing + below reads the entry point to find out which it got. Storage that stays durable whatever the + session — what the app has captured, as opposed to what the owner configured — is separate, + provided under its own qualifier, so that "this outlives the lockscreen session" is a binding a + reviewer can see rather than a branch inside a store. **Unidirectional data flow per screen** (`ui//screen/`): @@ -425,6 +446,5 @@ migrating the UI is exactly when they stop being reachable, and left behind they - **Never add a commit co-author unless the user explicitly asks.** - Commit messages: imperative mood, describing the behavior change rather than the mechanism — match the existing log ("Don't initialize the camera while its permission is not granted"). -- Test-facing seams in `CamConfig` (`mPlayer`, `photoQuality`, `camera`, `switchMode`, - `SettingValues`) are written to by the instrumented suite. They stay writable until the screen - that owns them is migrated. +- Test-facing seams in `CamConfig` (`mPlayer`, `photoQuality`, `camera`, `switchMode`) are written + to by the instrumented suite. They stay writable until the screen that owns them is migrated. From 760482352151d7de5412ea3d9d93a84a82155bd4 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Thu, 3 Sep 2026 18:38:28 +0200 Subject: [PATCH 11/14] Stop keying video quality on its label --- app/config/ktlint/baseline.xml | 319 +++++++++--------- .../camera/ModeSwitchLatencyRegressionTest.kt | 6 +- .../java/app/grapheneos/camera/CamConfig.kt | 19 +- .../data/settings/model/VideoQualityTitle.kt | 31 -- .../grapheneos/camera/ui/SettingsDialog.kt | 47 +-- .../grapheneos/camera/ui/VideoQualityTitle.kt | 17 + app/src/main/res/values/strings.xml | 6 + .../data/settings/VideoQualityTitleTest.kt | 41 --- .../camera/ui/VideoQualityTitleTest.kt | 28 ++ 9 files changed, 236 insertions(+), 278 deletions(-) delete mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/model/VideoQualityTitle.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/VideoQualityTitle.kt delete mode 100644 app/src/test/java/app/grapheneos/camera/data/settings/VideoQualityTitleTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/ui/VideoQualityTitleTest.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index a5b2b5b4..c8d363de 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -67,94 +67,95 @@ - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + @@ -442,82 +443,78 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt index 7cd41cd4..f5a7ce07 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt @@ -157,11 +157,11 @@ class ModeSwitchLatencyRegressionTest { } } - // Both halves are needed: the setter behind updateVideoQuality() persists whichever quality - // the spinner is showing, not the one it is passed. + // Both halves are needed: setSelection() only posts its callback, so the quality is applied + // here rather than a looper pass later. private fun selectVideoQuality(activity: MainActivity, position: Int) { val dialog = activity.settingsDialog dialog.videoQualitySpinner.setSelection(position) - dialog.updateVideoQuality(dialog.videoQualitySpinner.getItemAtPosition(position) as String) + dialog.updateVideoQuality(dialog.videoQualities[position]) } } diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index e39e3ff2..ea6fe27e 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -57,6 +57,7 @@ import app.grapheneos.camera.data.settings.model.GridType import app.grapheneos.camera.data.settings.model.ModeSettings import app.grapheneos.camera.data.settings.model.SettingsDefaults import app.grapheneos.camera.data.settings.model.focusTimeoutLabel +import app.grapheneos.camera.ui.videoQualityTitle import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.ktx.applyPreviewRatio import app.grapheneos.camera.ui.activities.CaptureActivity @@ -1003,14 +1004,16 @@ class CamConfig( } } - // The quality labels shown in the settings spinner, so that a message about a quality can - // name it exactly the way the user picked it (see videoQualityTitle). - private fun describeQualityFeature(feature: GroupableFeature): String? = when (feature) { - GroupableFeatures.UHD_RECORDING -> "2160p (UHD)" - GroupableFeatures.FHD_RECORDING -> "1080p (FHD)" - GroupableFeatures.HD_RECORDING -> "720p (HD)" - GroupableFeatures.SD_RECORDING -> "480p (SD)" - else -> null + private fun describeQualityFeature(feature: GroupableFeature): String? { + val quality = when (feature) { + GroupableFeatures.UHD_RECORDING -> Quality.UHD + GroupableFeatures.FHD_RECORDING -> Quality.FHD + GroupableFeatures.HD_RECORDING -> Quality.HD + GroupableFeatures.SD_RECORDING -> Quality.SD + else -> return null + } + + return videoQualityTitle(mActivity, quality) } // Avoids repeating an unchanged notice: startCamera() runs again on every tab switch, diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/model/VideoQualityTitle.kt b/app/src/main/java/app/grapheneos/camera/data/settings/model/VideoQualityTitle.kt deleted file mode 100644 index ed756b7f..00000000 --- a/app/src/main/java/app/grapheneos/camera/data/settings/model/VideoQualityTitle.kt +++ /dev/null @@ -1,31 +0,0 @@ -package app.grapheneos.camera.data.settings.model - -import androidx.camera.video.Quality - -// TODO: move these into strings.xml, which they could not be while they were also the storage -// format. They wait for the settings screen rewrite that owns this setting. -private const val TITLE_UHD = "2160p (UHD)" -private const val TITLE_FHD = "1080p (FHD)" -private const val TITLE_HD = "720p (HD)" -private const val TITLE_SD = "480p (SD)" -private const val TITLE_UNKNOWN = "Unknown" - -fun videoQualityTitle(quality: Quality): String { - return when (quality) { - Quality.UHD -> TITLE_UHD - Quality.FHD -> TITLE_FHD - Quality.HD -> TITLE_HD - Quality.SD -> TITLE_SD - else -> TITLE_UNKNOWN - } -} - -fun videoQualityFromTitle(title: String): Quality { - return when (title) { - TITLE_UHD -> Quality.UHD - TITLE_FHD -> Quality.FHD - TITLE_HD -> Quality.HD - TITLE_SD -> Quality.SD - else -> Quality.SD - } -} diff --git a/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt index 398f12bd..e71dd82f 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt @@ -11,7 +11,6 @@ import android.graphics.Color import android.graphics.Rect import android.os.Handler import android.os.Looper -import android.util.Log import android.view.Gravity import android.view.MotionEvent import android.view.View @@ -25,7 +24,6 @@ import android.widget.ArrayAdapter import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout -import android.widget.RadioGroup import android.widget.ScrollView import android.widget.Spinner import android.widget.ToggleButton @@ -41,17 +39,13 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import androidx.core.view.ViewCompat -import app.grapheneos.camera.CamConfig import app.grapheneos.camera.R import app.grapheneos.camera.data.settings.model.GridType -import app.grapheneos.camera.data.settings.model.videoQualityFromTitle -import app.grapheneos.camera.data.settings.model.videoQualityTitle import app.grapheneos.camera.databinding.SettingsBinding import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.MoreSettings import com.google.android.material.color.MaterialColors import com.google.android.material.materialswitch.MaterialSwitch -import com.google.android.material.radiobutton.MaterialRadioButton import java.util.Collections import kotlin.math.max @@ -68,7 +62,9 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : var torchToggle: ToggleButton private var gridToggle: ImageView var videoQualitySpinner: Spinner - private lateinit var vQAdapter: ArrayAdapter + internal var videoQualities: List = emptyList() + private set + private var focusTimeoutSpinner: Spinner private var timerSpinner: Spinner @@ -240,9 +236,9 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : position: Int, p3: Long ) { + val quality = videoQualities.getOrNull(position) ?: return - val choice = vQAdapter.getItem(position) as String - updateVideoQuality(choice) + updateVideoQuality(quality) } override fun onNothingSelected(p0: AdapterView<*>?) {} @@ -568,10 +564,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : timerSpinner.setSelection(timeOptions.indexOf(option).coerceAtLeast(0), false) } - fun updateVideoQuality(choice: String, resCam: Boolean = true) { - - val quality = videoQualityFromTitle(choice) - + fun updateVideoQuality(quality: Quality, resCam: Boolean = true) { if (quality == camConfig.videoQuality) return camConfig.videoQuality = quality @@ -579,8 +572,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : if (resCam) { camConfig.startCamera(true) } else { - videoQualitySpinner.setSelection(getAvailableQTitles().indexOf(choice)) - + videoQualitySpinner.setSelection(videoQualities.indexOf(quality)) } } @@ -770,16 +762,6 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : return Recorder.getVideoCapabilities(cameraInfo).getSupportedQualities(DynamicRange.SDR) } - private fun getAvailableQTitles(): List { - val titles = arrayListOf() - - getAvailableQualities().forEach { - titles.add(videoQualityTitle(it)) - } - - return titles - } - fun updateGridToggleUI() { mActivity.previewGrid.postInvalidate() // The description has to travel with the drawable: this control cycles through four @@ -848,25 +830,22 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } fun reloadQualities() { + videoQualities = getAvailableQualities() - val titles = getAvailableQTitles() - - vQAdapter = ArrayAdapter( + val adapter = ArrayAdapter( mActivity, android.R.layout.simple_spinner_item, - titles + videoQualities.map { videoQualityTitle(mActivity, it) }, ) - vQAdapter.setDropDownViewResource( + adapter.setDropDownViewResource( android.R.layout.simple_spinner_dropdown_item ) - videoQualitySpinner.adapter = vQAdapter + videoQualitySpinner.adapter = adapter if (camConfig.videoQuality != Quality.HIGHEST) { - videoQualitySpinner.setSelection( - titles.indexOf(videoQualityTitle(camConfig.videoQuality)), - ) + videoQualitySpinner.setSelection(videoQualities.indexOf(camConfig.videoQuality)) } } } diff --git a/app/src/main/java/app/grapheneos/camera/ui/VideoQualityTitle.kt b/app/src/main/java/app/grapheneos/camera/ui/VideoQualityTitle.kt new file mode 100644 index 00000000..9c45167f --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/VideoQualityTitle.kt @@ -0,0 +1,17 @@ +package app.grapheneos.camera.ui + +import android.content.Context +import androidx.camera.video.Quality +import app.grapheneos.camera.R + +fun videoQualityTitle(context: Context, quality: Quality): String { + val titleId = when (quality) { + Quality.UHD -> R.string.video_quality_uhd + Quality.FHD -> R.string.video_quality_fhd + Quality.HD -> R.string.video_quality_hd + Quality.SD -> R.string.video_quality_sd + else -> R.string.video_quality_unknown + } + + return context.getString(titleId) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8df97c25..af61a0a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -150,6 +150,12 @@ does not suggest rebooting. --> This mode is unavailable on this device. + 2160p (UHD) + 1080p (FHD) + 720p (HD) + 480p (SD) + Unknown +