diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..4d6ec2ba1 --- /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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..4e73b537b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,448 @@ +# 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/ + 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) + camera/ + model/ CameraCapabilities, lens/extension descriptors + repository/ CameraProviderSource + store/ ExtensionAvailabilityStore + media/ + model/ CapturedItem and friends + repository/ CapturedItemRepository + store/ CapturedItemStore, 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. +- **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. +- **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`. + **Anything that opens preferences is the exception**: it is a `@Provides` in an + `ActivityComponent` module, built on `@ActivityContext`, and it is `@ActivityScoped` rather than + `@Reusable`. Whether a session gets the owner's files or a throwaway copy of them is decided + there, 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 a separate file, 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/`): + +- 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`) 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 000000000..47dc3e3d8 --- /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 000000000..47dc3e3d8 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4becedc2a..413ef754a 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,64 @@ if (useKeystoreProperties) { plugins { alias(libs.plugins.android.application) + alias(libs.plugins.detekt) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +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 { @@ -89,6 +150,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 { @@ -97,10 +166,19 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.coroutines.core) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.bundles.camerax) 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/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml new file mode 100644 index 000000000..4ba23a5b2 --- /dev/null +++ b/app/config/ktlint/baseline.xml @@ -0,0 +1,872 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml new file mode 100644 index 000000000..6be137247 --- /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: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 + 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: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 + 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: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!! + 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 000000000..9686b59c1 --- /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/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt index de86d7da6..1dfa006cb 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/EntryPointContractTest.kt b/app/src/androidTest/java/app/grapheneos/camera/EntryPointContractTest.kt new file mode 100644 index 000000000..42e2b5c73 --- /dev/null +++ b/app/src/androidTest/java/app/grapheneos/camera/EntryPointContractTest.kt @@ -0,0 +1,175 @@ +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 org.junit.Assert.assertEquals +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 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"), + ) + } + + @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, + ) + } + } + + @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/SafTreeGrantsRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt index 1893d21e5..7fe4fe907 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt @@ -2,13 +2,18 @@ package app.grapheneos.camera 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 androidx.test.platform.app.InstrumentationRegistry +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.media.store.CapturedItemStore +import app.grapheneos.camera.data.media.store.CapturedItemStoreImpl +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import app.grapheneos.camera.data.settings.repository.SettingsRepositoryImpl import app.grapheneos.camera.util.EphemeralSharedPrefs -import app.grapheneos.camera.util.edit +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -31,31 +36,35 @@ 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 targetSdk = InstrumentationRegistry + .getInstrumentation() + .targetContext + .applicationInfo + .targetSdkVersion + + private fun session(): Session { + return Session(targetSdk) + } /** 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) + private fun pickStorageLocation(session: Session, treeUri: Uri) { + session.store.currentSafTree()?.let { + session.store.trackSafTree(it) } - prefs.edit { - putString(SettingValues.Key.STORAGE_LOCATION, treeUri.toString()) + runBlocking { + session.settings.setStorageLocation(treeUri.toString()).collect() } } /** 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 = session.store.safTrees() assertEquals(CapturedItems.MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES + 1, tracked.size) picked.take(picked.size - tracked.size).forEach { @@ -75,11 +84,11 @@ 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 = session.store.safTrees() 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)) @@ -97,7 +106,6 @@ class SafTreeGrantsRegressionTest { assertEquals(0, CapturedItems.safTreeFlagsToRelease(mediaStore, true, true, tracked)) } - /** The release covers exactly the modes the grant holds, and a grant holding none is skipped. */ @Test fun onlyTheModesTheGrantHoldsAreReleased() { val untracked = tree("untracked") @@ -116,4 +124,27 @@ class SafTreeGrantsRegressionTest { ) assertEquals(0, CapturedItems.safTreeFlagsToRelease(untracked, false, false, tracked)) } + + /** + * The store and the settings share one commons, because the storage location the user picks is + * written by the settings and read back by the store. The captures file stays separate, as it is + * in the app: aliasing the two here would let a confusion between them pass unnoticed. + */ + private class Session( + targetSdk: Int, + ) { + + private val commons = EphemeralSharedPrefs(targetSdk) + + private val media = EphemeralSharedPrefs(targetSdk) + + val store: CapturedItemStore = CapturedItemStoreImpl(commons = commons, media = media) + + val settings: SettingsRepository = SettingsRepositoryImpl( + commons = commons, + modePreferences = CameraMode.entries.associateWith { + lazy { EphemeralSharedPrefs(targetSdk) } + }, + ) + } } 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 000000000..eb6bb89e1 --- /dev/null +++ b/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt @@ -0,0 +1,173 @@ +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.data.core.model.CameraMode +import app.grapheneos.camera.data.core.store.commonPreferences +import app.grapheneos.camera.data.core.store.modePreferences +import app.grapheneos.camera.data.settings.repository.SettingsKeys +import app.grapheneos.camera.ui.activities.MainActivity +import app.grapheneos.camera.ui.activities.SecureMainActivity +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Asserts the isolation through the repository the activities actually got. A binding that handed a + * secure session the persistent preferences — 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 { + @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 persistentCommons(): SharedPreferences { + return commonPreferences(context, ephemeral = false) + } + + private fun persistentModePrefs(): SharedPreferences { + return modePreferences(context, ephemeral = false).getValue(MODE).value + } + + private var ownersPhotoQuality: Int? = null + private var ownersGeoTagging: Boolean? = null + + @Before + fun rememberOwnersSettings() { + ownersPhotoQuality = persistentCommons() + .takeIf { it.contains(SettingsKeys.PHOTO_QUALITY) } + ?.getInt(SettingsKeys.PHOTO_QUALITY, -1) + ownersGeoTagging = persistentModePrefs() + .takeIf { it.contains(SettingsKeys.GEO_TAGGING) } + ?.getBoolean(SettingsKeys.GEO_TAGGING, false) + } + + /** These are real settings, so the device is left configured the way it was found. */ + @After + fun restoreOwnersSettings() { + persistentCommons().edit().apply { + when (val quality = ownersPhotoQuality) { + null -> remove(SettingsKeys.PHOTO_QUALITY) + else -> putInt(SettingsKeys.PHOTO_QUALITY, quality) + } + }.commit() + + persistentModePrefs().edit().apply { + when (val geoTagging = ownersGeoTagging) { + null -> remove(SettingsKeys.GEO_TAGGING) + else -> putBoolean(SettingsKeys.GEO_TAGGING, geoTagging) + } + }.commit() + } + + @Test + fun writesInASecureSessionDoNotChangeThePersistentPrefs() { + persistentCommons().edit().putInt(SettingsKeys.PHOTO_QUALITY, OWNERS_QUALITY).commit() + + ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + runBlocking { + activity.settingsRepository.setPhotoQuality(SESSIONS_QUALITY).collect() + } + } + } + + assertEquals( + "A secure session wrote through to the persistent preferences", + OWNERS_QUALITY, + persistentCommons().getInt(SettingsKeys.PHOTO_QUALITY, -1), + ) + } + + @Test + fun aSecureSessionStillReadsTheOwnersSettings() { + persistentCommons().edit().putInt(SettingsKeys.PHOTO_QUALITY, OWNERS_QUALITY).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", + OWNERS_QUALITY, + activity.settingsRepository.settings.value.photoQuality, + ) + } + } + } + + /** A session handed a fresh copy on every lookup would read the owner's value back. */ + @Test + fun aSecureSessionKeepsItsModeSettingsToItselfAndThenKeepsThem() { + persistentModePrefs().edit().putBoolean(SettingsKeys.GEO_TAGGING, false).commit() + + ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val repository = activity.settingsRepository + + repository.reslotMode(mode = MODE, isFrontFacing = false) + runBlocking { repository.setGeoTagging(true).collect() } + repository.reslotMode(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", + repository.modeSettings.value.geoTagging, + ) + } + } + + assertFalse( + "A secure session wrote through to the persistent mode preferences", + persistentModePrefs().getBoolean(SettingsKeys.GEO_TAGGING, false), + ) + } + + @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 -> + runBlocking { + activity.settingsRepository.setPhotoQuality(SESSIONS_QUALITY).collect() + } + } + } + + assertEquals( + SESSIONS_QUALITY, + persistentCommons().getInt(SettingsKeys.PHOTO_QUALITY, -1), + ) + } + + private companion object { + val MODE = CameraMode.VIDEO + + const val OWNERS_QUALITY = 71 + const val SESSIONS_QUALITY = 42 + } +} diff --git a/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt index 6373271ef..2d9f828bd 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 e479b352f..c5ad6ce07 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/App.kt b/app/src/main/java/app/grapheneos/camera/App.kt index 7d59a1d80..94559026a 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 d558133f7..627181cb7 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 @@ -24,6 +22,7 @@ import androidx.camera.core.AspectRatio import androidx.camera.core.Camera import androidx.camera.core.CameraInfo import androidx.camera.core.CameraSelector +import androidx.camera.core.DynamicRange import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageCapture import androidx.camera.core.MirrorMode @@ -39,7 +38,6 @@ import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.extensions.ExtensionMode import androidx.camera.extensions.ExtensionsManager import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.core.DynamicRange import androidx.camera.video.GroupableFeatures import androidx.camera.video.Quality import androidx.camera.video.QualitySelector @@ -50,6 +48,14 @@ 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.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 @@ -65,118 +71,15 @@ import com.google.zxing.BarcodeFormat 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), -} +import kotlinx.coroutines.flow.collect +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" @@ -203,8 +106,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() @@ -265,35 +166,31 @@ 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 val settings: CameraSettings + get() { + return settingsRepository.settings.value + } + + private val modeSettings: ModeSettings + get() { + return settingsRepository.modeSettings.value + } var lastCapturedItem: CapturedItem? = null init { if (mActivity !is SecureActivity) { - CapturedItems.init(mActivity, this) + capturedItemRepository.migrateStoredCaptures(::updateLastCapturedItem) + capturedItemRepository.releaseUntrackedSafTrees() fetchLastCapturedItemFromSharedPrefs() } } 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) - - 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)) - } - } - lastCapturedItem = item + val item = capturedItemRepository.lastCapturedItem() + val skip = item?.type == ITEM_TYPE_IMAGE && mActivity is VideoOnlyActivity + + lastCapturedItem = if (skip) null else item } @@ -342,17 +239,14 @@ class CamConfig(private val mActivity: MainActivity) { AspectRatio.RATIO_4_3 } 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() + runBlocking { + settingsRepository.setAspectRatio(value).collect() + } } var lensFacing = DEFAULT_LENS_FACING @@ -361,101 +255,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 + runBlocking { + settingsRepository.setGridType(value).collect() + } } - 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).collect() + } + } - modePref.edit { - putString(videoQualityKey, option) + var flashMode: Int = SettingsDefaults.FLASH_MODE + set(value) { + runBlocking { + settingsRepository.setFlashMode(value).collect() } 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) { + runBlocking { + settingsRepository.setFocusTimeoutSeconds(value).collect() } - - 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" + runBlocking { + settingsRepository.setSelfTimerDurationSeconds(value).collect() } - - 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() + runBlocking { + settingsRepository.setEnableCameraSounds(value).collect() + } } 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() + runBlocking { + settingsRepository.setScanAllCodes(value).collect() + } if (isQRMode) { if (value) { @@ -476,133 +344,106 @@ 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() + runBlocking { + settingsRepository.setIncludeAudio(value).collect() + } 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() + runBlocking { + settingsRepository.setEnableEis(value).collect() + } 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() + runBlocking { + settingsRepository.setEnableZsl(value).collect() + } } 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() + runBlocking { + settingsRepository.setSaveImageAsPreviewed(value).collect() + } } 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() + runBlocking { + settingsRepository.setSaveVideoAsPreviewed(value).collect() + } } var storageLocation: String get() { - return commonPref.getString( - SettingValues.Key.STORAGE_LOCATION, - SettingValues.Default.STORAGE_LOCATION - )!! + return settings.storageLocation } set(value) { val cur = storageLocation - if (cur != SettingValues.Default.STORAGE_LOCATION) { - CapturedItems.savePreviousSafTree(Uri.parse(cur), commonPref) + if (cur != SettingsDefaults.STORAGE_LOCATION) { + capturedItemRepository.trackPreviousStorageLocation(Uri.parse(cur)) } - val editor = commonPref.edit() - editor.putString(SettingValues.Key.STORAGE_LOCATION, value) - editor.apply() + runBlocking { + settingsRepository.setStorageLocation(value).collect() + } // 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 + // stored location, and re-picking a tree that the track above 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() + runBlocking { + settingsRepository.setPhotoQuality(value).collect() + } } 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() + runBlocking { + settingsRepository.setRemoveExifAfterCapture(value).collect() + } } 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() + runBlocking { + settingsRepository.setGyroscopeSuggestions(value).collect() + } } val isZslSupported : Boolean by lazy { @@ -652,41 +493,23 @@ 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) - } - } + 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).collect() } mActivity.settingsDialog.locToggle.isChecked = value @@ -696,15 +519,12 @@ 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).collect() } mActivity.settingsDialog.selfIlluminationToggle.isChecked = value @@ -715,10 +535,8 @@ class CamConfig(private val mActivity: MainActivity) { fun setQRScanningFor(format: String, selected: Boolean) { - val formatSRep = "${SettingValues.Key.SCAN}_$format" - - commonPref.edit { - putBoolean(formatSRep, selected) + runBlocking { + settingsRepository.setBarcodeFormatEnabled(formatName = format, enabled = selected).collect() } if (selected) { @@ -738,186 +556,49 @@ class CamConfig(private val mActivity: MainActivity) { qrAnalyzer?.refreshHints() } + private fun slotCurrentMode() { + settingsRepository.reslotMode( + mode = currentMode, + isFrontFacing = lensFacing == CameraSelector.LENS_FACING_FRONT, + ) + } + 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) - } + settingsRepository.refresh() - if (!modePref.contains(SettingValues.Key.GEO_TAGGING)) { - putBoolean(SettingValues.Key.GEO_TAGGING, SettingValues.Default.GEO_TAGGING) - } + slotCurrentMode() - if (isVideoMode) { - mActivity.settingsDialog.reloadQualities() - } - - 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 (settingsRepository.isBarcodeFormatEnabled(format.name)) { if (format !in allowedFormats) { allowedFormats.add(format) } @@ -945,57 +626,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) + runBlocking { + settingsRepository.setWaitForFocusLock(value).collect() } } 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) + runBlocking { + settingsRepository.setSelectHighestResolution(value).collect() } } - 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 } @@ -1300,7 +948,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)" @@ -1396,7 +1044,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 @@ -1959,6 +1611,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 @@ -1974,7 +1640,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) { @@ -2072,13 +1738,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(settingsRepository.isBarcodeFormatEnabled(format.name)) } builder.setMultiChoiceItems( @@ -2110,24 +1770,25 @@ 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 optionValue = optionValues[index] - val format = BarcodeFormat.valueOf(optionName) + val format = BarcodeFormat.valueOf(optionName) - if (optionValue) { - if (format !in allowedFormats) - allowedFormats.add(format) - } else { - allowedFormats.remove(format) + if (optionValue) { + if (format !in allowedFormats) { + allowedFormats.add(format) } + } else { + allowedFormats.remove(format) + } - putBoolean(formatSRep, optionValue) + runBlocking { + settingsRepository.setBarcodeFormatEnabled( + formatName = optionName, + enabled = optionValue, + ).collect() } } @@ -2151,7 +1812,7 @@ class CamConfig(private val mActivity: MainActivity) { fun onStorageLocationNotFound() { // Reverting back to DEFAULT_MEDIA_STORE_CAPTURE_PATH - storageLocation = SettingValues.Default.STORAGE_LOCATION + storageLocation = SettingsDefaults.STORAGE_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 91417ba0c..4fb65b2bb 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 6477716c3..552399dd0 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.settings.model.SettingsDefaults 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 == SettingsDefaults.STORAGE_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 83819c7d9..17b874d36 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.settings.model.SettingsDefaults 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 == SettingsDefaults.STORAGE_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/model/CameraMode.kt b/app/src/main/java/app/grapheneos/camera/data/core/model/CameraMode.kt new file mode 100644 index 000000000..395103260 --- /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/data/core/store/PreferenceFiles.kt b/app/src/main/java/app/grapheneos/camera/data/core/store/PreferenceFiles.kt new file mode 100644 index 000000000..37d40840d --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/core/store/PreferenceFiles.kt @@ -0,0 +1,62 @@ +package app.grapheneos.camera.data.core.store + +import android.content.Context +import android.content.SharedPreferences +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.util.EphemeralSharedPrefs + +/** Everything the owner configured that is not scoped to one camera mode. */ +private const val COMMON_PREFS_NAME = "commons" + +private const val MEDIA_PREFS_NAME = "media" + +/** Shared with the media feature. Empty means the MediaStore, which holds no SAF grant. */ +internal const val STORAGE_LOCATION_KEY = "storage_location" + +internal fun commonPreferences( + context: Context, + ephemeral: Boolean, +): SharedPreferences { + return preferences( + context = context, + name = COMMON_PREFS_NAME, + ephemeral = ephemeral, + ) +} + +/** Never ephemeral: a lockscreen capture is a real file the owner's gallery has to point at. */ +internal fun mediaPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences(MEDIA_PREFS_NAME, Context.MODE_PRIVATE) +} + +/** + * One file per mode, opened once: a second ephemeral copy would be re-read from the owner's file and + * would silently discard everything the session had changed since the first. + */ +internal fun modePreferences( + context: Context, + ephemeral: Boolean, +): Map> { + return CameraMode + .entries + .associateWith { mode -> + lazy { + preferences( + context = context, + name = mode.name, + ephemeral = ephemeral, + ) + } + } +} + +private fun preferences( + context: Context, + name: String, + ephemeral: Boolean, +): SharedPreferences { + return when { + ephemeral -> EphemeralSharedPrefs.copyOf(context = context, name = name) + else -> context.getSharedPreferences(name, Context.MODE_PRIVATE) + } +} 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 000000000..21f0dcdbc --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/repository/CapturedItemRepository.kt @@ -0,0 +1,249 @@ +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 app.grapheneos.camera.BuildConfig +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.CapturedItems +import app.grapheneos.camera.data.media.store.CapturedItemStore +import dagger.hilt.android.qualifiers.ActivityContext +import javax.inject.Inject + +interface CapturedItemRepository { + + fun lastCapturedItem(): CapturedItem? + + fun saveLastCapturedItem(item: CapturedItem) + + fun trackPreviousStorageLocation(treeUri: Uri) + + fun releaseUntrackedSafTrees() + + fun migrateStoredCaptures(onLastCapturedItem: (CapturedItem) -> Unit) + + @Throws(InterruptedException::class) + fun capturedItems(): List +} + +internal class CapturedItemRepositoryImpl @Inject constructor( + private val store: CapturedItemStore, + @ActivityContext private val context: Context, +) : CapturedItemRepository { + + override fun lastCapturedItem(): CapturedItem? { + return store.readLastCapturedItem() + } + + override fun saveLastCapturedItem(item: CapturedItem) { + store.writeLastCapturedItem(item) + } + + override fun trackPreviousStorageLocation(treeUri: Uri) { + store.trackSafTree(treeUri) + } + + @Suppress("TooGenericExceptionCaught") + override fun releaseUntrackedSafTrees() { + val tracked = store.safTrees() + 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 fun migrateStoredCaptures(onLastCapturedItem: (CapturedItem) -> Unit) { + store.migrateLastCapturedItem() + + val joinedUris = store.readLegacyMediaUris() ?: return + + store.replaceLegacyMediaUris( + legacyTrees(joinedUris = joinedUris, onLastCapturedItem = onLastCapturedItem), + ) + } + + override fun capturedItems(): List { + val resolver = context.contentResolver + val items = ArrayList() + + collectMediaStoreItems(resolver, MediaStore.VOLUME_EXTERNAL_PRIMARY, items) + + store.safTrees().forEach { + if (Thread.interrupted()) { + throw InterruptedException() + } + collectSafItems(resolver, it, items) + } + + return items.distinct() + } + + @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 fun legacyTrees( + joinedUris: String, + onLastCapturedItem: (CapturedItem) -> Unit, + ): List { + val currentTreeUri = store.currentSafTree() + 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 fun releaseUntrackedSafTrees() { + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/media/store/CapturedItemStore.kt b/app/src/main/java/app/grapheneos/camera/data/media/store/CapturedItemStore.kt new file mode 100644 index 000000000..c2ce38439 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/store/CapturedItemStore.kt @@ -0,0 +1,158 @@ +package app.grapheneos.camera.data.media.store + +import android.content.SharedPreferences +import android.net.Uri +import androidx.core.net.toUri +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.CapturedItems +import app.grapheneos.camera.data.core.store.STORAGE_LOCATION_KEY +import app.grapheneos.camera.di.core.DurablePreferences +import app.grapheneos.camera.di.core.SessionPreferences +import app.grapheneos.camera.util.edit +import javax.inject.Inject + +interface CapturedItemStore { + + fun readLastCapturedItem(): CapturedItem? + + fun writeLastCapturedItem(item: CapturedItem) + + /** The location captures are saved to now, or null while they go to the MediaStore. */ + fun currentSafTree(): Uri? + + /** Every location the gallery lists: the current one first, then the tracked previous ones. */ + fun safTrees(): List + + fun previousSafTrees(): List + + fun trackSafTree(treeUri: Uri) + + fun readLegacyMediaUris(): String? + + /** Records [trees] and drops the legacy list in one batch, so the migration cannot half-run. */ + fun replaceLegacyMediaUris(trees: List) + + fun migrateLastCapturedItem() +} + +internal class CapturedItemStoreImpl @Inject constructor( + @SessionPreferences private val commons: SharedPreferences, + @DurablePreferences private val media: SharedPreferences, +) : CapturedItemStore { + + override fun readLastCapturedItem(): CapturedItem? { + return lastCapturedItemIn(media) + } + + override fun writeLastCapturedItem(item: CapturedItem) { + media.edit { + putInt(LAST_CAPTURED_ITEM_TYPE, item.type) + putString(LAST_CAPTURED_ITEM_DATE_STRING, item.dateString) + putString(LAST_CAPTURED_ITEM_URI, item.uri.toString()) + } + } + + override fun currentSafTree(): Uri? { + return commons.getString(STORAGE_LOCATION_KEY, null) + ?.takeIf { it.isNotEmpty() } + ?.toUri() + } + + override fun safTrees(): List { + val trees = ArrayList() + + currentSafTree()?.let { + trees.add(it) + } + + trees.addAll(previousSafTrees()) + + return trees.distinct() + } + + override fun previousSafTrees(): List { + val stored = commons.getString(PREVIOUS_SAF_TREES_KEY, null) ?: return emptyList() + + return stored.split(CapturedItems.SAF_TREE_SEPARATOR).map { it.toUri() } + } + + override fun trackSafTree(treeUri: Uri) { + val trees = previousSafTrees().toMutableList() + + trees.remove(treeUri) + trees.add(0, treeUri) + + while (trees.size > CapturedItems.MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES) { + // trees.removeLast() requires API level 35 now due to Java adding it + trees.removeAt(trees.lastIndex) + } + + commons.edit { + putPreviousSafTrees(trees, this) + } + } + + override fun readLegacyMediaUris(): String? { + return commons.getString(LEGACY_MEDIA_URIS, null) + } + + override fun replaceLegacyMediaUris(trees: List) { + commons.edit { + putPreviousSafTrees(trees, this) + remove(LEGACY_MEDIA_URIS) + } + } + + override fun migrateLastCapturedItem() { + if (!commons.contains(LAST_CAPTURED_ITEM_DATE_STRING)) { + return + } + + if (!media.contains(LAST_CAPTURED_ITEM_DATE_STRING)) { + lastCapturedItemIn(commons)?.let(::writeLastCapturedItem) + } + + commons.edit { + remove(LAST_CAPTURED_ITEM_TYPE) + remove(LAST_CAPTURED_ITEM_DATE_STRING) + remove(LAST_CAPTURED_ITEM_URI) + } + } + + private fun lastCapturedItemIn(preferences: SharedPreferences): CapturedItem? { + val dateString = preferences.getString(LAST_CAPTURED_ITEM_DATE_STRING, null) + val uri = preferences.getString(LAST_CAPTURED_ITEM_URI, null) + + return when { + dateString == null || uri == null -> null + else -> { + CapturedItem( + type = preferences.getInt(LAST_CAPTURED_ITEM_TYPE, -1), + dateString = dateString, + uri = uri.toUri(), + ) + } + } + } + + private fun putPreviousSafTrees(trees: List, editor: SharedPreferences.Editor) { + if (trees.isEmpty()) { + return + } + + editor.putString( + PREVIOUS_SAF_TREES_KEY, + trees.joinToString(separator = CapturedItems.SAF_TREE_SEPARATOR), + ) + } + + companion object { + private const val LAST_CAPTURED_ITEM_TYPE = "last_captured_item_type" + private const val LAST_CAPTURED_ITEM_DATE_STRING = "last_captured_item_date_string" + private const val LAST_CAPTURED_ITEM_URI = "last_captured_item_uri" + + private const val PREVIOUS_SAF_TREES_KEY = "previous_saf_trees" + + private const val LEGACY_MEDIA_URIS = "media_uri_s" + } +} 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 000000000..809678114 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/CameraSettings.kt @@ -0,0 +1,45 @@ +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 storageLocation: String = SettingsDefaults.STORAGE_LOCATION, +) { + companion object { + const val FOCUS_TIMEOUT_OFF = "Off" + } +} + +fun focusTimeoutSecondsFromLabel(label: String?): Long { + return when (label) { + null -> SettingsDefaults.FOCUS_TIMEOUT_SECONDS + CameraSettings.FOCUS_TIMEOUT_OFF -> 0L + else -> { + label + .removeSuffix("s") + .toLongOrNull() + ?: SettingsDefaults.FOCUS_TIMEOUT_SECONDS + } + } +} + +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 000000000..cdad712e7 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/GridType.kt @@ -0,0 +1,12 @@ +package app.grapheneos.camera.data.settings.model + +/** + * Persisted by ordinal, so the order of these constants is the storage format: inserting one + * anywhere but the end re-labels every stored grid. + */ +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 000000000..f337abc38 --- /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 000000000..7e7dc6c2a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/SettingsDefaults.kt @@ -0,0 +1,50 @@ +package app.grapheneos.camera.data.settings.model + +import androidx.camera.core.AspectRatio +import androidx.camera.core.ImageCapture +import androidx.camera.video.Quality + +object SettingsDefaults { + + val GRID_TYPE = GridType.NONE + + val VIDEO_QUALITY: Quality = Quality.HIGHEST + + 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 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 +} 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 000000000..030ff8bb7 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/VideoQualityTitle.kt @@ -0,0 +1,45 @@ +package app.grapheneos.camera.data.settings.model + +import androidx.camera.video.Quality + +// These are the persisted form of the video quality setting as well as the displayed one: the +// per-mode preferences hold the title string rather than the Quality itself, so changing one +// orphans every install that had picked it. +// +// TODO: store the Quality by name rather than by label, so the labels can move into strings.xml. +// It needs a read-time migration of every label already stored, so it waits 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 storableVideoQualityTitle(quality) ?: TITLE_UNKNOWN +} + +/** + * The title [quality] may be stored under, or null for one that has none: [Quality.HIGHEST] and + * [Quality.LOWEST] name whatever the device offers rather than a resolution, and storing their + * placeholder title would read back through [videoQualityFromTitle] as SD. + */ +fun storableVideoQualityTitle(quality: Quality): String? { + return when (quality) { + Quality.UHD -> TITLE_UHD + Quality.FHD -> TITLE_FHD + Quality.HD -> TITLE_HD + Quality.SD -> TITLE_SD + else -> null + } +} + +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/SettingsKeys.kt b/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsKeys.kt new file mode 100644 index 000000000..786c16b6f --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsKeys.kt @@ -0,0 +1,43 @@ +package app.grapheneos.camera.data.settings.repository + +/** The preference keys the settings are stored under. Renaming one resets that setting. */ +internal object SettingsKeys { + + 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 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" + + fun scanKey(formatName: String): String { + return "${SCAN}_$formatName" + } +} 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 000000000..d191417ef --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsRepository.kt @@ -0,0 +1,583 @@ +package app.grapheneos.camera.data.settings.repository + +import android.content.SharedPreferences +import androidx.camera.video.Quality +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.core.store.STORAGE_LOCATION_KEY +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.model.focusTimeoutSecondsFromLabel +import app.grapheneos.camera.data.settings.model.storableVideoQualityTitle +import app.grapheneos.camera.data.settings.model.videoQualityFromTitle +import app.grapheneos.camera.di.core.SessionPreferences +import app.grapheneos.camera.util.edit +import app.grapheneos.camera.util.unitFlow +import com.google.zxing.BarcodeFormat +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +interface SettingsRepository { + + val settings: StateFlow + + fun refresh() + + fun setAspectRatio(value: Int): Flow + + fun setGridType(value: GridType): Flow + + fun setFocusTimeoutSeconds(value: Long): Flow + + fun setSelfTimerDurationSeconds(value: Int): Flow + + fun setEnableCameraSounds(value: Boolean): Flow + + fun setIncludeAudio(value: Boolean): Flow + + fun setEnableEis(value: Boolean): Flow + + fun setEnableZsl(value: Boolean): Flow + + fun setWaitForFocusLock(value: Boolean): Flow + + fun setSelectHighestResolution(value: Boolean): Flow + + fun setPhotoQuality(value: Int): Flow + + fun setRemoveExifAfterCapture(value: Boolean): Flow + + fun setGyroscopeSuggestions(value: Boolean): Flow + + fun setSaveImageAsPreviewed(value: Boolean): Flow + + fun setSaveVideoAsPreviewed(value: Boolean): Flow + + fun setScanAllCodes(value: Boolean): Flow + + fun setStorageLocation(value: String): Flow + + fun isBarcodeFormatEnabled(formatName: String): Boolean + + fun setBarcodeFormatEnabled(formatName: String, enabled: Boolean): Flow + + val modeSettings: StateFlow + + fun reslotMode(mode: CameraMode, isFrontFacing: Boolean) + + fun setFlashMode(value: Int): Flow + + fun setGeoTagging(value: Boolean): Flow + + fun setSelfIllumination(value: Boolean): Flow + + fun setVideoQuality(value: Quality): Flow +} + +internal class SettingsRepositoryImpl @Inject constructor( + @SessionPreferences private val commons: SharedPreferences, + private val modePreferences: Map>, +) : SettingsRepository { + + private val mutableSettings = MutableStateFlow( + run { + seedCommonDefaults() + readCommonSettings() + }, + ) + + override val settings: StateFlow = mutableSettings.asStateFlow() + + override fun refresh() { + mutableSettings.value = readCommonSettings() + } + + private val mutableModeSettings = MutableStateFlow(ModeSettings()) + + private var slotted: SlottedMode? = null + + override fun setAspectRatio(value: Int): Flow { + return write({ it.copy(aspectRatio = value) }) { + commons.edit { + putInt(SettingsKeys.ASPECT_RATIO, value) + } + } + } + + override fun setGridType(value: GridType): Flow { + return write({ it.copy(gridType = value) }) { + commons.edit { + putInt(SettingsKeys.GRID, value.ordinal) + } + } + } + + override fun setFocusTimeoutSeconds(value: Long): Flow { + return write({ it.copy(focusTimeoutSeconds = value) }) { + commons.edit { + putString(SettingsKeys.FOCUS_TIMEOUT, focusTimeoutLabel(value)) + } + } + } + + override fun setSelfTimerDurationSeconds(value: Int): Flow { + return write({ it.copy(selfTimerDurationSeconds = value) }) { + commons.edit { + putInt(SettingsKeys.SELF_TIMER_DURATION, value) + } + } + } + + override fun setEnableCameraSounds(value: Boolean): Flow { + return write({ it.copy(enableCameraSounds = value) }) { + commons.edit { + putBoolean(SettingsKeys.CAMERA_SOUNDS, value) + } + } + } + + override fun setIncludeAudio(value: Boolean): Flow { + return write({ it.copy(includeAudio = value) }) { + commons.edit { + putBoolean(SettingsKeys.INCLUDE_AUDIO, value) + } + } + } + + override fun setEnableEis(value: Boolean): Flow { + return write({ it.copy(enableEis = value) }) { + commons.edit { + putBoolean(SettingsKeys.ENABLE_EIS, value) + } + } + } + + override fun setEnableZsl(value: Boolean): Flow { + return write({ it.copy(enableZsl = value) }) { + commons.edit { + putBoolean(SettingsKeys.ENABLE_ZSL, value) + } + } + } + + override fun setWaitForFocusLock(value: Boolean): Flow { + return write({ it.copy(waitForFocusLock = value) }) { + commons.edit { + putBoolean(SettingsKeys.WAIT_FOR_FOCUS_LOCK, value) + } + } + } + + override fun setSelectHighestResolution(value: Boolean): Flow { + return write({ it.copy(selectHighestResolution = value) }) { + commons.edit { + putBoolean(SettingsKeys.SELECT_HIGHEST_RESOLUTION, value) + } + } + } + + override fun setPhotoQuality(value: Int): Flow { + return write({ it.copy(photoQuality = value) }) { + storePhotoQuality(value) + } + } + + override fun setRemoveExifAfterCapture(value: Boolean): Flow { + return write({ it.copy(removeExifAfterCapture = value) }) { + commons.edit { + putBoolean(SettingsKeys.REMOVE_EXIF_AFTER_CAPTURE, value) + } + } + } + + override fun setGyroscopeSuggestions(value: Boolean): Flow { + return write({ it.copy(gyroscopeSuggestions = value) }) { + commons.edit { + putBoolean(SettingsKeys.GYROSCOPE_SUGGESTIONS, value) + } + } + } + + override fun setSaveImageAsPreviewed(value: Boolean): Flow { + return write({ it.copy(saveImageAsPreviewed = value) }) { + commons.edit { + putBoolean(SettingsKeys.SAVE_IMAGE_AS_PREVIEW, value) + } + } + } + + override fun setSaveVideoAsPreviewed(value: Boolean): Flow { + return write({ it.copy(saveVideoAsPreviewed = value) }) { + commons.edit { + putBoolean(SettingsKeys.SAVE_VIDEO_AS_PREVIEW, value) + } + } + } + + override fun setScanAllCodes(value: Boolean): Flow { + return write({ it.copy(scanAllCodes = value) }) { + commons.edit { + putBoolean(SettingsKeys.SCAN_ALL_CODES, value) + } + } + } + + override fun setStorageLocation(value: String): Flow { + return write({ it.copy(storageLocation = value) }) { + commons.edit { + putString(STORAGE_LOCATION_KEY, value) + } + } + } + + override fun isBarcodeFormatEnabled(formatName: String): Boolean { + return commons.getBoolean(SettingsKeys.scanKey(formatName), false) + } + + override fun setBarcodeFormatEnabled(formatName: String, enabled: Boolean): Flow { + return unitFlow { + commons.edit { + putBoolean(SettingsKeys.scanKey(formatName), enabled) + } + } + } + + override val modeSettings: StateFlow = mutableModeSettings.asStateFlow() + + override fun reslotMode(mode: CameraMode, isFrontFacing: Boolean) { + seedModeDefaults(mode = mode, isFrontFacing = isFrontFacing) + + slotted = SlottedMode(mode = mode, isFrontFacing = isFrontFacing) + mutableModeSettings.value = readModeSettings(mode = mode, isFrontFacing = isFrontFacing) + } + + override fun setFlashMode(value: Int): Flow { + return writeMode({ it.copy(flashMode = value) }) { mode -> + preferencesFor(mode.mode).edit { + putInt(SettingsKeys.FLASH_MODE, value) + } + } + } + + override fun setGeoTagging(value: Boolean): Flow { + return writeMode({ it.copy(geoTagging = value) }) { mode -> + preferencesFor(mode.mode).edit { + putBoolean(SettingsKeys.GEO_TAGGING, value) + } + } + } + + override fun setSelfIllumination(value: Boolean): Flow { + return writeMode({ it.copy(selfIllumination = value) }) { mode -> + preferencesFor(mode.mode).edit { + putBoolean(SettingsKeys.SELF_ILLUMINATION, value) + } + } + } + + override fun setVideoQuality(value: Quality): Flow { + return writeMode({ it.copy(videoQuality = value) }) { mode -> + val qualityKey = videoQualityKey(mode.isFrontFacing) + + preferencesFor(mode.mode).edit { + when (val title = storableVideoQualityTitle(value)) { + null -> remove(qualityKey) + else -> putString(qualityKey, title) + } + } + } + } + + private fun write( + update: (CameraSettings) -> CameraSettings, + persist: () -> Unit, + ): Flow { + return unitFlow { + mutableSettings.update(update) + persist() + } + } + + private fun writeMode( + update: (ModeSettings) -> ModeSettings, + persist: (SlottedMode) -> Unit, + ): Flow { + return unitFlow { + slotted?.let { mode -> + mutableModeSettings.update(update) + persist(mode) + } + } + } + + private fun readCommonSettings(): CameraSettings { + val gridOrdinal = commons.getInt( + SettingsKeys.GRID, + SettingsDefaults.GRID_TYPE.ordinal, + ) + + return CameraSettings( + aspectRatio = commons.getInt( + SettingsKeys.ASPECT_RATIO, + SettingsDefaults.ASPECT_RATIO, + ), + gridType = GridType.entries.getOrElse(gridOrdinal) { SettingsDefaults.GRID_TYPE }, + focusTimeoutSeconds = focusTimeoutSecondsFromLabel( + commons.getString( + SettingsKeys.FOCUS_TIMEOUT, + focusTimeoutLabel(SettingsDefaults.FOCUS_TIMEOUT_SECONDS), + ), + ), + selfTimerDurationSeconds = commons.getInt( + SettingsKeys.SELF_TIMER_DURATION, + SettingsDefaults.SELF_TIMER_DURATION, + ), + photoQuality = commons.getInt( + SettingsKeys.PHOTO_QUALITY, + SettingsDefaults.PHOTO_QUALITY, + ), + storageLocation = commons.getString( + STORAGE_LOCATION_KEY, + SettingsDefaults.STORAGE_LOCATION, + ).orEmpty(), + ).withStoredToggles() + } + + private fun CameraSettings.withStoredToggles(): CameraSettings { + return copy( + enableCameraSounds = commons.getBoolean( + SettingsKeys.CAMERA_SOUNDS, + SettingsDefaults.CAMERA_SOUNDS, + ), + includeAudio = commons.getBoolean( + SettingsKeys.INCLUDE_AUDIO, + SettingsDefaults.INCLUDE_AUDIO, + ), + enableEis = commons.getBoolean( + SettingsKeys.ENABLE_EIS, + SettingsDefaults.ENABLE_EIS, + ), + enableZsl = commons.getBoolean( + SettingsKeys.ENABLE_ZSL, + SettingsDefaults.ENABLE_ZSL, + ), + waitForFocusLock = commons.getBoolean( + SettingsKeys.WAIT_FOR_FOCUS_LOCK, + SettingsDefaults.WAIT_FOR_FOCUS_LOCK, + ), + selectHighestResolution = commons.getBoolean( + SettingsKeys.SELECT_HIGHEST_RESOLUTION, + SettingsDefaults.SELECT_HIGHEST_RESOLUTION, + ), + removeExifAfterCapture = commons.getBoolean( + SettingsKeys.REMOVE_EXIF_AFTER_CAPTURE, + SettingsDefaults.REMOVE_EXIF_AFTER_CAPTURE, + ), + gyroscopeSuggestions = commons.getBoolean( + SettingsKeys.GYROSCOPE_SUGGESTIONS, + SettingsDefaults.GYROSCOPE_SUGGESTIONS, + ), + saveImageAsPreviewed = commons.getBoolean( + SettingsKeys.SAVE_IMAGE_AS_PREVIEW, + SettingsDefaults.SAVE_IMAGE_AS_PREVIEW, + ), + saveVideoAsPreviewed = commons.getBoolean( + SettingsKeys.SAVE_VIDEO_AS_PREVIEW, + SettingsDefaults.SAVE_VIDEO_AS_PREVIEW, + ), + scanAllCodes = commons.getBoolean( + SettingsKeys.SCAN_ALL_CODES, + SettingsDefaults.SCAN_ALL_CODES, + ), + ) + } + + private fun readModeSettings(mode: CameraMode, isFrontFacing: Boolean): ModeSettings { + val modePrefs = preferencesFor(mode) + val qualityKey = videoQualityKey(isFrontFacing) + + return ModeSettings( + flashMode = modePrefs.getInt( + SettingsKeys.FLASH_MODE, + SettingsDefaults.FLASH_MODE, + ), + geoTagging = modePrefs.getBoolean( + SettingsKeys.GEO_TAGGING, + SettingsDefaults.GEO_TAGGING, + ), + selfIllumination = modePrefs.getBoolean( + SettingsKeys.SELF_ILLUMINATION, + SettingsDefaults.SELF_ILLUMINATION, + ), + videoQuality = when { + modePrefs.contains(qualityKey) -> { + videoQualityFromTitle(modePrefs.getString(qualityKey, "").orEmpty()) + } + else -> SettingsDefaults.VIDEO_QUALITY + }, + ) + } + + private fun preferencesFor(mode: CameraMode): SharedPreferences { + return modePreferences.getValue(mode).value + } + + private fun videoQualityKey(isFrontFacing: Boolean): String { + return when { + isFrontFacing -> "${SettingsKeys.VIDEO_QUALITY}_FRONT" + else -> "${SettingsKeys.VIDEO_QUALITY}_BACK" + } + } + + private fun storePhotoQuality(value: Int) { + commons.edit { + putInt(SettingsKeys.PHOTO_QUALITY, value) + } + } + + private fun seedCommonDefaults() { + commons.edit { + seedSaveAsPreviewed(this) + seedBarcodeFormats(this) + + if (!commons.contains(SettingsKeys.CAMERA_SOUNDS)) { + putBoolean( + SettingsKeys.CAMERA_SOUNDS, + SettingsDefaults.CAMERA_SOUNDS, + ) + } + + if (!commons.contains(SettingsKeys.GRID)) { + putInt(SettingsKeys.GRID, SettingsDefaults.GRID_TYPE.ordinal) + } + + if (!commons.contains(SettingsKeys.FOCUS_TIMEOUT)) { + putString( + SettingsKeys.FOCUS_TIMEOUT, + focusTimeoutLabel(SettingsDefaults.FOCUS_TIMEOUT_SECONDS), + ) + } + + if (!commons.contains(SettingsKeys.INCLUDE_AUDIO)) { + putBoolean( + SettingsKeys.INCLUDE_AUDIO, + SettingsDefaults.INCLUDE_AUDIO, + ) + } + + if (!commons.contains(SettingsKeys.ENABLE_EIS)) { + putBoolean(SettingsKeys.ENABLE_EIS, SettingsDefaults.ENABLE_EIS) + } + + if (!commons.contains(SettingsKeys.ASPECT_RATIO)) { + putInt(SettingsKeys.ASPECT_RATIO, SettingsDefaults.ASPECT_RATIO) + } + + if (!commons.contains(SettingsKeys.SCAN_ALL_CODES)) { + putBoolean( + SettingsKeys.SCAN_ALL_CODES, + SettingsDefaults.SCAN_ALL_CODES, + ) + } + } + + migrateFromLegacyPhotoQuality() + } + + private fun seedSaveAsPreviewed(editor: SharedPreferences.Editor) { + if (commons.contains(SettingsKeys.SAVE_IMAGE_AS_PREVIEW)) { + if (!commons.contains(SettingsKeys.SAVE_VIDEO_AS_PREVIEW)) { + editor.putBoolean(SettingsKeys.SAVE_VIDEO_AS_PREVIEW, false) + } + return + } + + editor.putBoolean( + SettingsKeys.SAVE_IMAGE_AS_PREVIEW, + SettingsDefaults.SAVE_IMAGE_AS_PREVIEW, + ) + editor.putBoolean( + SettingsKeys.SAVE_VIDEO_AS_PREVIEW, + SettingsDefaults.SAVE_VIDEO_AS_PREVIEW, + ) + } + + private fun seedBarcodeFormats(editor: SharedPreferences.Editor) { + val qrKey = SettingsKeys.scanKey(BarcodeFormat.QR_CODE.name) + + if (commons.contains(qrKey)) { + return + } + + BarcodeFormat.entries.forEach { format -> + editor.putBoolean(SettingsKeys.scanKey(format.name), false) + } + editor.putBoolean(qrKey, true) + } + + private fun migrateFromLegacyPhotoQuality() { + if (commons.contains(SettingsKeys.EMPHASIS_ON_QUALITY)) { + if (!commons.contains(SettingsKeys.PHOTO_QUALITY)) { + val optimizeForQuality = commons.getBoolean( + SettingsKeys.EMPHASIS_ON_QUALITY, + false, + ) + storePhotoQuality( + if (optimizeForQuality) MAX_PHOTO_QUALITY else SettingsDefaults.PHOTO_QUALITY, + ) + } + + commons.edit { + remove(SettingsKeys.EMPHASIS_ON_QUALITY) + } + } + + val stored = commons.getInt( + SettingsKeys.PHOTO_QUALITY, + SettingsDefaults.PHOTO_QUALITY, + ) + + if (stored == 0) { + storePhotoQuality(SettingsDefaults.PHOTO_QUALITY) + } + } + + private fun seedModeDefaults(mode: CameraMode, isFrontFacing: Boolean) { + val modePrefs = preferencesFor(mode) + + modePrefs.edit { + if (!modePrefs.contains(SettingsKeys.FLASH_MODE)) { + putInt(SettingsKeys.FLASH_MODE, SettingsDefaults.FLASH_MODE) + } + + if (!modePrefs.contains(SettingsKeys.GEO_TAGGING)) { + putBoolean(SettingsKeys.GEO_TAGGING, SettingsDefaults.GEO_TAGGING) + } + + val needsSelfIllumination = isFrontFacing && + !modePrefs.contains(SettingsKeys.SELF_ILLUMINATION) + + if (needsSelfIllumination) { + putBoolean( + SettingsKeys.SELF_ILLUMINATION, + SettingsDefaults.SELF_ILLUMINATION, + ) + } + } + } + + private data class SlottedMode( + val mode: CameraMode, + val isFrontFacing: Boolean, + ) + + private companion object { + private const val MAX_PHOTO_QUALITY = 100 + } +} 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 000000000..a2af6a359 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/core/Qualifiers.kt @@ -0,0 +1,13 @@ +package app.grapheneos.camera.di.core + +import javax.inject.Qualifier + +/** Preferences that outlive the session writing them, whichever entry point that session is. */ +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class DurablePreferences + +/** Preferences scoped to the running session. The default for anything the owner configured. */ +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class SessionPreferences diff --git a/app/src/main/java/app/grapheneos/camera/di/media/MediaBindsModule.kt b/app/src/main/java/app/grapheneos/camera/di/media/MediaBindsModule.kt new file mode 100644 index 000000000..63f3c77b4 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/media/MediaBindsModule.kt @@ -0,0 +1,18 @@ +package app.grapheneos.camera.di.media + +import app.grapheneos.camera.data.media.store.CapturedItemStore +import app.grapheneos.camera.data.media.store.CapturedItemStoreImpl +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 MediaBindsModule { + + @Binds + @ActivityScoped + abstract fun bindCapturedItemStore(impl: CapturedItemStoreImpl): CapturedItemStore +} 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 000000000..972281149 --- /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/PreferencesProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt new file mode 100644 index 000000000..5e8e353bf --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt @@ -0,0 +1,55 @@ +package app.grapheneos.camera.di.preferences + +import android.content.Context +import android.content.SharedPreferences +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.core.store.commonPreferences +import app.grapheneos.camera.data.core.store.mediaPreferences +import app.grapheneos.camera.data.core.store.modePreferences +import app.grapheneos.camera.di.core.DurablePreferences +import app.grapheneos.camera.di.core.SessionPreferences +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.qualifiers.ApplicationContext +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +internal class PreferencesProvidesModule { + + @Provides + @ActivityScoped + @SessionPreferences + fun provideSessionPreferences( + @ActivityContext context: Context, + ): SharedPreferences { + return commonPreferences( + context = context, + ephemeral = context is SecureActivity, + ) + } + + @Provides + @ActivityScoped + @DurablePreferences + fun provideDurablePreferences( + @ApplicationContext context: Context, + ): SharedPreferences { + return mediaPreferences(context) + } + + @Provides + @ActivityScoped + fun provideModePreferences( + @ActivityContext context: Context, + ): Map> { + return modePreferences( + context = context, + ephemeral = context is SecureActivity, + ) + } +} 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 000000000..b65333628 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsBindsModule.kt @@ -0,0 +1,18 @@ +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/ui/BottomTabLayout.kt b/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt index c1dfbda23..a383a94d9 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt @@ -4,7 +4,7 @@ import android.content.Context import android.util.AttributeSet import android.view.MotionEvent import android.view.ViewGroup -import app.grapheneos.camera.CameraMode +import app.grapheneos.camera.data.core.model.CameraMode import com.google.android.material.tabs.TabLayout class BottomTabLayout @JvmOverloads constructor( 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 4510c656e..7a65d8c81 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 1fab33867..398f12bde 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 49828bb26..34792d090 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,22 @@ 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 +@AndroidEntryPoint class InAppGallery : AppCompatActivity() { + @Inject + lateinit var capturedItemRepository: CapturedItemRepository + lateinit var binding: GalleryBinding lateinit var gallerySlider: ViewPager2 var gallerySliderAdapter: GallerySliderAdapter? = null @@ -661,7 +667,7 @@ class InAppGallery : AppCompatActivity() { asyncLoaderOfCapturedItems.execute { val unprocessedItems: List = try { - CapturedItems.get(this) + 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 5412822c2..bec973f7f 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 @@ -67,13 +67,15 @@ 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.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 @@ -100,15 +102,18 @@ 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 +@AndroidEntryPoint open class MainActivity : AppCompatActivity(), OnTouchListener, OnScaleGestureListener, @@ -116,6 +121,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 @@ -614,7 +625,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) 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 ef5c03b77..99f176eec 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.settings.model.SettingsDefaults 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 = SettingsDefaults.STORAGE_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 b0c1ff957..838f1cf40 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/QrTile.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/QrTile.kt index ad3ad3f08..5843750de 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. // 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 bbd919e37..ecf63c447 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 504242681..106f7fe04 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 b362222e2..cd78317cd 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/EphemeralSharedPrefs.kt similarity index 87% rename from app/src/main/java/app/grapheneos/camera/util/SharedPrefs.kt rename to app/src/main/java/app/grapheneos/camera/util/EphemeralSharedPrefs.kt index 9da49d2e5..e49c8ce16 100644 --- a/app/src/main/java/app/grapheneos/camera/util/SharedPrefs.kt +++ b/app/src/main/java/app/grapheneos/camera/util/EphemeralSharedPrefs.kt @@ -4,33 +4,10 @@ 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() @@ -151,6 +128,23 @@ class EphemeralSharedPrefs(val targetSdk: Int) : SharedPreferences { } } } + + companion object { + + fun copyOf(context: Context, name: String): EphemeralSharedPrefs { + val copy = EphemeralSharedPrefs(context.applicationInfo.targetSdkVersion) + val stored = context.applicationContext.getSharedPreferences( + name, + Context.MODE_PRIVATE, + ) + + stored.all.forEach { (key, value) -> + copy.map[key] = value + } + + return copy + } + } } @SuppressLint("ApplySharedPref") diff --git a/app/src/main/java/app/grapheneos/camera/util/FlowExtensions.kt b/app/src/main/java/app/grapheneos/camera/util/FlowExtensions.kt new file mode 100644 index 000000000..fc8abbed0 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/util/FlowExtensions.kt @@ -0,0 +1,14 @@ +package app.grapheneos.camera.util + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow + +inline fun unitFlow( + crossinline block: suspend FlowCollector.() -> Unit, +): Flow { + return flow { + block() + emit(Unit) + } +} 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 34529b0b6..3ff2a9dca 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.settings.model.SettingsDefaults 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 == SettingsDefaults.STORAGE_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/PreferenceFilesTest.kt b/app/src/test/java/app/grapheneos/camera/data/core/PreferenceFilesTest.kt new file mode 100644 index 000000000..9238c22c8 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/core/PreferenceFilesTest.kt @@ -0,0 +1,41 @@ +package app.grapheneos.camera.data.core + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.core.store.modePreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PreferenceFilesTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + /** A second in-memory copy would be re-read from the owner's file. */ + @Test + fun modePreferences_repeatedLookups_returnTheSameFile() { + val preferences = modePreferences(context, ephemeral = true) + val first = preferences.getValue(CameraMode.VIDEO).value + + first.edit().putInt("photoQuality", 42).commit() + + assertSame(first, preferences.getValue(CameraMode.VIDEO).value) + assertEquals(42, preferences.getValue(CameraMode.VIDEO).value.getInt("photoQuality", -1)) + } + + @Test + fun modePreferences_eachMode_getsItsOwnFile() { + val preferences = modePreferences(context, ephemeral = true) + + preferences.getValue(CameraMode.VIDEO).value.edit().putBoolean("geo_tagging", true).commit() + + assertFalse( + preferences.getValue(CameraMode.CAMERA).value.getBoolean("geo_tagging", false), + ) + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/media/CapturedItemStoreTest.kt b/app/src/test/java/app/grapheneos/camera/data/media/CapturedItemStoreTest.kt new file mode 100644 index 000000000..57e8d2efd --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/media/CapturedItemStoreTest.kt @@ -0,0 +1,331 @@ +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.content.SharedPreferences +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.DocumentsContract +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.STORAGE_LOCATION_KEY +import app.grapheneos.camera.data.core.store.commonPreferences +import app.grapheneos.camera.data.core.store.mediaPreferences +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.data.media.store.CapturedItemStore +import app.grapheneos.camera.data.media.store.CapturedItemStoreImpl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +/** + * A lockscreen capture must still reach the owner's gallery while nothing else about the session + * does, and the legacy uri migration runs against installs nobody can rebuild. + */ +@RunWith(RobolectricTestRunner::class) +class CapturedItemStoreTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private fun persistentCommons(): SharedPreferences { + return commonPreferences(context, ephemeral = false) + } + + private fun persistentMedia(): SharedPreferences { + return mediaPreferences(context) + } + + private fun store(ephemeral: Boolean = false): CapturedItemStore { + return CapturedItemStoreImpl( + commons = commonPreferences(context, ephemeral = ephemeral), + media = persistentMedia(), + ) + } + + private fun repository(store: CapturedItemStore): CapturedItemRepository { + return CapturedItemRepositoryImpl(store = store, context = context) + } + + 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) + } + + @Before + fun clearPersistentPrefs() { + persistentCommons().edit().clear().commit() + persistentMedia().edit().clear().commit() + } + + @Test + fun readLastCapturedItem_afterRestart_returnsTheStoredItem() { + store().writeLastCapturedItem(item(DATE_STRING)) + + val reloaded = store().readLastCapturedItem() + + assertEquals(item(DATE_STRING), reloaded) + assertEquals(ITEM_TYPE_IMAGE, reloaded?.type) + } + + @Test + fun readLastCapturedItem_freshInstall_returnsNull() { + assertNull(store().readLastCapturedItem()) + } + + @Test + fun lockscreenSession_captures_reachTheCapturesFileAndNothingElse() { + val session = store(ephemeral = true) + + session.writeLastCapturedItem(item(DATE_STRING)) + session.trackSafTree(treeUri("treeA")) + + assertEquals(item(DATE_STRING), session.readLastCapturedItem()) + assertEquals(item(DATE_STRING), store().readLastCapturedItem()) + assertEquals(emptyList(), store().previousSafTrees()) + } + + /** + * A release is durable and cannot be undone when the session ends, so the repository a lockscreen + * session gets must not reach the ContentResolver at all — a Context that throws on any access + * to it is the only way to assert "never" rather than "not with this fixture's data". + */ + @Test + fun releaseUntrackedSafTrees_lockscreenSession_touchesNoPersistedGrant() { + val repository = LockscreenCapturedItemRepository( + CapturedItemRepositoryImpl( + store = store(ephemeral = true), + context = NoContentResolverContext(context), + ), + ) + + repository.releaseUntrackedSafTrees() + } + + @Test + fun migrateStoredCaptures_lastCapturedItemInCommons_movesItToTheCapturesFile() { + writeLegacyLastCapturedItem(DATE_STRING) + + repository(store()).migrateStoredCaptures { } + + assertEquals(item(DATE_STRING), store().readLastCapturedItem()) + assertFalse(persistentCommons().contains(LEGACY_LAST_CAPTURED_ITEM_DATE_STRING)) + } + + @Test + fun migrateStoredCaptures_capturesFileAlreadyPopulated_leavesItAlone() { + store().writeLastCapturedItem(item(DATE_STRING)) + writeLegacyLastCapturedItem(OLDER_DATE_STRING) + + repository(store()).migrateStoredCaptures { } + + assertEquals(item(DATE_STRING), store().readLastCapturedItem()) + } + + @Test + fun init_doesNotMigrateAnything() { + writeLegacyLastCapturedItem(DATE_STRING) + + store() + + assertNull(persistentMedia().getString(LEGACY_LAST_CAPTURED_ITEM_DATE_STRING, null)) + assertEquals( + DATE_STRING, + persistentCommons().getString(LEGACY_LAST_CAPTURED_ITEM_DATE_STRING, null), + ) + } + + @Test + fun trackSafTree_beyondTheCap_keepsTheMostRecentOnly() { + val store = store() + val tracked = CapturedItems.MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES + + (0..tracked).forEach { index -> + store.trackSafTree(treeUri("tree$index")) + } + + assertEquals( + (tracked downTo 1).map { treeUri("tree$it") }, + store().previousSafTrees(), + ) + } + + @Test + fun migrateStoredCaptures_legacyUris_becomeTrackedTreesOnce() { + val prefs = persistentCommons() + val legacy = listOf("treeA", "treeB").joinToString(separator = ";") { + documentUri(it).toString() + } + prefs.edit().putString(LEGACY_MEDIA_URIS, legacy).commit() + + repository(store()).migrateStoredCaptures { } + + assertFalse(prefs.contains(LEGACY_MEDIA_URIS)) + assertEquals( + listOf(treeUri("treeA"), treeUri("treeB")), + store().previousSafTrees(), + ) + + store().trackSafTree(treeUri("treeC")) + repository(store()).migrateStoredCaptures { } + + assertEquals( + listOf(treeUri("treeC"), treeUri("treeA"), treeUri("treeB")), + store().previousSafTrees(), + ) + } + + @Test + fun migrateStoredCaptures_currentStorageLocation_isNotTrackedAsAPreviousOne() { + val prefs = persistentCommons() + prefs.edit() + .putString(STORAGE_LOCATION_KEY, treeUri("treeA").toString()) + .putString(LEGACY_MEDIA_URIS, documentUri("treeA").toString()) + .commit() + + repository(store()).migrateStoredCaptures { } + + assertEquals(emptyList(), store().previousSafTrees()) + } + + @Test + fun migrateStoredCaptures_moreLegacyTreesThanTheCap_keepsThemAll() { + val prefs = persistentCommons() + val cap = CapturedItems.MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES + val trees = (0..cap).map { "tree$it" } + prefs.edit() + .putString( + LEGACY_MEDIA_URIS, + trees.joinToString(separator = ";") { documentUri(it).toString() }, + ) + .commit() + + repository(store()).migrateStoredCaptures { } + + assertEquals(trees.map { treeUri(it) }, store().previousSafTrees()) + + store().trackSafTree(treeUri("picked")) + + assertEquals( + listOf(treeUri("picked")) + trees.take(cap - 1).map { treeUri(it) }, + store().previousSafTrees(), + ) + } + + @Test + fun migrateStoredCaptures_legacyUris_reportTheMostRecentAsTheLastCapturedItem() { + Robolectric.buildContentProvider(FakeDocumentsProvider::class.java).create(AUTHORITY) + val prefs = persistentCommons() + prefs.edit().putString(LEGACY_MEDIA_URIS, documentUri("treeA").toString()).commit() + + var reported: CapturedItem? = null + repository(store()).migrateStoredCaptures { reported = it } + + assertEquals( + CapturedItem( + type = ITEM_TYPE_IMAGE, + dateString = DATE_STRING, + uri = documentUri("treeA"), + ), + reported, + ) + } + + private fun writeLegacyLastCapturedItem(dateString: String) { + val stored = item(dateString) + + persistentCommons().edit() + .putInt(LEGACY_LAST_CAPTURED_ITEM_TYPE, stored.type) + .putString(LEGACY_LAST_CAPTURED_ITEM_DATE_STRING, stored.dateString) + .putString(LEGACY_LAST_CAPTURED_ITEM_URI, stored.uri.toString()) + .commit() + } + + 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() + } + } + + companion object { + private const val AUTHORITY = "com.example.documents" + private const val DATE_STRING = "20260724_153012_345" + private const val OLDER_DATE_STRING = "20250101_090000_000" + private const val LEGACY_MEDIA_URIS = "media_uri_s" + private const val LEGACY_LAST_CAPTURED_ITEM_TYPE = "last_captured_item_type" + private const val LEGACY_LAST_CAPTURED_ITEM_DATE_STRING = "last_captured_item_date_string" + private const val LEGACY_LAST_CAPTURED_ITEM_URI = "last_captured_item_uri" + } +} 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 000000000..bb54c1f1d --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/settings/SettingsRepositoryTest.kt @@ -0,0 +1,335 @@ +package app.grapheneos.camera.data.settings + +import android.content.Context +import android.content.SharedPreferences +import androidx.camera.video.Quality +import androidx.test.core.app.ApplicationProvider +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.core.store.commonPreferences +import app.grapheneos.camera.data.core.store.modePreferences +import app.grapheneos.camera.data.settings.model.GridType +import app.grapheneos.camera.data.settings.repository.SettingsKeys +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import app.grapheneos.camera.data.settings.repository.SettingsRepositoryImpl +import app.grapheneos.camera.util.edit +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * A write must land before it returns, on the thread that made it: the in-memory preferences a + * lockscreen session gets crash if their editor is used from another thread. + */ +@RunWith(RobolectricTestRunner::class) +class SettingsRepositoryTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private fun persistentCommons(): SharedPreferences { + return commonPreferences(context, ephemeral = false) + } + + private fun persistentModePrefs(mode: CameraMode): SharedPreferences { + return context.getSharedPreferences(mode.name, Context.MODE_PRIVATE) + } + + private fun repository(ephemeral: Boolean = false): SettingsRepository { + return SettingsRepositoryImpl( + commons = commonPreferences(context, ephemeral = ephemeral), + modePreferences = modePreferences(context, ephemeral = ephemeral), + ) + } + + private fun threadRecordingRepository(threads: MutableList): SettingsRepository { + return SettingsRepositoryImpl( + commons = ThreadRecordingPrefs(persistentCommons(), threads), + modePreferences = CameraMode.entries.associateWith { mode -> + lazy { ThreadRecordingPrefs(persistentModePrefs(mode), threads) } + }, + ) + } + + @Before + fun clearPersistentPrefs() { + persistentCommons().edit().clear().commit() + persistentModePrefs(MODE).edit().clear().commit() + } + + /** The camera rebinds off the snapshot, so a write must be in it by the time collection ends. */ + @Test + fun write_flowCollected_isVisibleInTheSnapshot() { + val repository = repository() + + runBlocking { repository.setAspectRatio(SOME_ASPECT_RATIO).collect() } + runBlocking { repository.setGridType(GridType.GOLDEN_RATIO).collect() } + runBlocking { repository.setPhotoQuality(SOME_PHOTO_QUALITY).collect() } + + assertEquals(SOME_ASPECT_RATIO, repository.settings.value.aspectRatio) + assertEquals(GridType.GOLDEN_RATIO, repository.settings.value.gridType) + assertEquals(SOME_PHOTO_QUALITY, repository.settings.value.photoQuality) + } + + /** The writes are cold, so a caller that drops the Flow silently drops the setting with it. */ + @Test + @Suppress("IgnoredReturnValue") + fun write_flowNotCollected_doesNothing() { + val repository = repository() + + repository.setPhotoQuality(SOME_PHOTO_QUALITY) + + assertEquals(DEFAULT_PHOTO_QUALITY, repository.settings.value.photoQuality) + assertFalse(persistentCommons().contains(SettingsKeys.PHOTO_QUALITY)) + } + + @Test + fun write_flowCollected_reachesThePreferencesBeforeReturning() { + val repository = repository() + + runBlocking { repository.setPhotoQuality(SOME_PHOTO_QUALITY).collect() } + + assertEquals( + SOME_PHOTO_QUALITY, + persistentCommons().getInt(SettingsKeys.PHOTO_QUALITY, -1), + ) + } + + @Test + fun write_alwaysRunsOnTheCallersThread() { + val editorThreads = mutableListOf() + val repository = threadRecordingRepository(editorThreads) + + runBlocking { repository.setEnableEis(false).collect() } + runBlocking { repository.setStorageLocation("content://tree/example").collect() } + repository.reslotMode(mode = MODE, isFrontFacing = true) + runBlocking { repository.setSelfIllumination(true).collect() } + + assertTrue(editorThreads.isNotEmpty()) + editorThreads.forEach { thread -> + assertSame(Thread.currentThread(), thread) + } + } + + @Test + fun write_lockscreenSession_neverTouchesThePersistentPreferences() { + persistentCommons().edit { + putInt(SettingsKeys.PHOTO_QUALITY, SOME_PHOTO_QUALITY) + } + + val repository = repository(ephemeral = true) + + assertEquals(SOME_PHOTO_QUALITY, repository.settings.value.photoQuality) + + runBlocking { repository.setPhotoQuality(OTHER_PHOTO_QUALITY).collect() } + repository.reslotMode(mode = MODE, isFrontFacing = false) + runBlocking { repository.setGeoTagging(true).collect() } + + assertEquals(OTHER_PHOTO_QUALITY, repository.settings.value.photoQuality) + assertEquals( + SOME_PHOTO_QUALITY, + persistentCommons().getInt(SettingsKeys.PHOTO_QUALITY, -1), + ) + assertFalse(persistentModePrefs(MODE).contains(SettingsKeys.GEO_TAGGING)) + } + + /** A session handed a fresh in-memory copy per lookup would read the owner's value back. */ + @Test + fun writeMode_lockscreenSession_keepsItsOwnWrites() { + val repository = repository(ephemeral = true) + + repository.reslotMode(mode = MODE, isFrontFacing = false) + runBlocking { repository.setGeoTagging(true).collect() } + repository.reslotMode(mode = MODE, isFrontFacing = false) + + assertTrue(repository.modeSettings.value.geoTagging) + } + + @Test + fun writeMode_noModeSlotted_dropsTheWrite() { + val repository = repository() + + runBlocking { repository.setGeoTagging(true).collect() } + runBlocking { repository.setVideoQuality(Quality.UHD).collect() } + + assertFalse(repository.modeSettings.value.geoTagging) + assertFalse(persistentModePrefs(MODE).contains(SettingsKeys.GEO_TAGGING)) + + repository.reslotMode(mode = MODE, isFrontFacing = false) + runBlocking { repository.setGeoTagging(true).collect() } + + assertTrue(repository.modeSettings.value.geoTagging) + assertTrue(persistentModePrefs(MODE).getBoolean(SettingsKeys.GEO_TAGGING, false)) + } + + @Test + fun videoQuality_unset_readsAsHighestRatherThanTheMappersFallback() { + val repository = repository() + + repository.reslotMode(mode = MODE, isFrontFacing = false) + assertEquals(Quality.HIGHEST, repository.modeSettings.value.videoQuality) + + runBlocking { repository.setVideoQuality(Quality.UHD).collect() } + repository.reslotMode(mode = MODE, isFrontFacing = false) + assertEquals(Quality.UHD, repository.modeSettings.value.videoQuality) + } + + /** HIGHEST has no title of its own, and the placeholder one reads back as the lowest quality. */ + @Test + fun setVideoQuality_highest_clearsTheKeyRatherThanStoringAPlaceholder() { + val repository = repository() + + repository.reslotMode(mode = MODE, isFrontFacing = false) + runBlocking { repository.setVideoQuality(Quality.UHD).collect() } + runBlocking { repository.setVideoQuality(Quality.HIGHEST).collect() } + + repository.reslotMode(mode = MODE, isFrontFacing = false) + assertEquals(Quality.HIGHEST, repository.modeSettings.value.videoQuality) + } + + @Test + fun setVideoQuality_eachLensFacing_isStoredSeparately() { + val repository = repository() + + repository.reslotMode(mode = MODE, isFrontFacing = false) + runBlocking { repository.setVideoQuality(Quality.UHD).collect() } + + repository.reslotMode(mode = MODE, isFrontFacing = true) + assertEquals(Quality.HIGHEST, repository.modeSettings.value.videoQuality) + + repository.reslotMode(mode = MODE, isFrontFacing = false) + assertEquals(Quality.UHD, repository.modeSettings.value.videoQuality) + } + + @Test + fun writeMode_eachMode_isStoredSeparately() { + val repository = repository() + + repository.reslotMode(mode = MODE, isFrontFacing = false) + runBlocking { repository.setGeoTagging(true).collect() } + + repository.reslotMode(mode = OTHER_MODE, isFrontFacing = false) + assertFalse(repository.modeSettings.value.geoTagging) + + repository.reslotMode(mode = MODE, isFrontFacing = false) + assertTrue(repository.modeSettings.value.geoTagging) + } + + @Test + fun init_installPredatingSaveAsPreviewed_keepsRecordingTheOldWay() { + persistentCommons().edit { + putBoolean(SettingsKeys.SAVE_IMAGE_AS_PREVIEW, true) + } + + val repository = repository() + + assertTrue(repository.settings.value.saveImageAsPreviewed) + assertFalse(repository.settings.value.saveVideoAsPreviewed) + } + + @Test + fun init_freshInstall_savesBothAsPreviewed() { + val repository = repository() + + assertTrue(repository.settings.value.saveImageAsPreviewed) + assertTrue(repository.settings.value.saveVideoAsPreviewed) + } + + /** + * The snapshot is taken in a field initializer, so a migration that ran after construction + * would never reach it — and a stored quality of 0 handed to `ImageCapture.setJpegQuality` + * throws, which is the crash this migration exists to prevent. + */ + @Test + fun init_pendingMigrations_areVisibleInTheFirstSnapshot() { + persistentCommons().edit { + putInt(SettingsKeys.PHOTO_QUALITY, 0) + } + + val repository = repository() + + assertEquals(DEFAULT_PHOTO_QUALITY, repository.settings.value.photoQuality) + } + + @Test + fun init_legacyQualityEmphasis_isMigratedOnceAndThenLeftAlone() { + persistentCommons().edit { + putBoolean(SettingsKeys.EMPHASIS_ON_QUALITY, true) + } + + val repository = repository() + + assertEquals(MAX_PHOTO_QUALITY, repository.settings.value.photoQuality) + assertFalse(persistentCommons().contains(SettingsKeys.EMPHASIS_ON_QUALITY)) + + // A later change must survive the migration running again on the next launch. + runBlocking { repository.setPhotoQuality(SOME_PHOTO_QUALITY).collect() } + val relaunched = repository() + + assertEquals(SOME_PHOTO_QUALITY, relaunched.settings.value.photoQuality) + assertEquals( + SOME_PHOTO_QUALITY, + persistentCommons().getInt(SettingsKeys.PHOTO_QUALITY, -1), + ) + } + + @Test + fun init_relaunch_leavesSeededBarcodeFormatsAlone() { + val repository = repository() + + assertTrue(repository.isBarcodeFormatEnabled(QR_CODE_FORMAT)) + + runBlocking { + repository.setBarcodeFormatEnabled(formatName = QR_CODE_FORMAT, enabled = false) + .collect() + } + + val relaunched = repository() + + assertFalse(relaunched.isBarcodeFormatEnabled(QR_CODE_FORMAT)) + } + + private class ThreadRecordingPrefs( + private val delegate: SharedPreferences, + private val threads: MutableList, + ) : SharedPreferences by delegate { + + override fun edit(): SharedPreferences.Editor { + return RecordingEditor(delegate.edit(), threads) + } + + private class RecordingEditor( + private val delegate: SharedPreferences.Editor, + private val threads: MutableList, + ) : SharedPreferences.Editor by delegate { + + override fun apply() { + threads.add(Thread.currentThread()) + delegate.apply() + } + + override fun commit(): Boolean { + threads.add(Thread.currentThread()) + return delegate.commit() + } + } + } + + private companion object { + val MODE = CameraMode.VIDEO + val OTHER_MODE = CameraMode.CAMERA + + const val QR_CODE_FORMAT = "QR_CODE" + + const val SOME_ASPECT_RATIO = 1 + const val SOME_PHOTO_QUALITY = 71 + const val OTHER_PHOTO_QUALITY = 42 + const val MAX_PHOTO_QUALITY = 100 + const val DEFAULT_PHOTO_QUALITY = 95 + } +} 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 000000000..f4536f420 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/settings/VideoQualityTitleTest.kt @@ -0,0 +1,53 @@ +package app.grapheneos.camera.data.settings + +import androidx.camera.video.Quality +import app.grapheneos.camera.data.settings.model.storableVideoQualityTitle +import app.grapheneos.camera.data.settings.model.videoQualityFromTitle +import app.grapheneos.camera.data.settings.model.videoQualityTitle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The titles are the persisted form of the video quality setting, not just spinner labels, so a + * renamed one silently resets every install that had picked it. + */ +@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_keepTheirStoredWording() { + 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("")) + } + + @Test + fun storableVideoQualityTitle_qualityNamingNoResolution_returnsNull() { + assertNull(storableVideoQualityTitle(Quality.HIGHEST)) + assertNull(storableVideoQualityTitle(Quality.LOWEST)) + } +} 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 000000000..15a33720b --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModuleTest.kt @@ -0,0 +1,76 @@ +package app.grapheneos.camera.di.preferences + +import android.app.Activity +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.grapheneos.camera.data.core.store.commonPreferences +import app.grapheneos.camera.ui.activities.MoreSettings +import app.grapheneos.camera.ui.activities.MoreSettingsSecure +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.Robolectric +import org.robolectric.RobolectricTestRunner + +/** + * A screen marked secure is one the lockscreen can reach, and marking it is the whole of what stops + * it writing the owner's files — nothing below this module reads the entry point again. + */ +@RunWith(RobolectricTestRunner::class) +class PreferencesProvidesModuleTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val module = PreferencesProvidesModule() + + @Before + fun clearOwnerPreferences() { + commonPreferences(context, ephemeral = false).edit().clear().commit() + } + + @Test + fun provideSessionPreferences_secureActivity_keepsWritesOutOfTheOwnersFile() { + val preferences = module.provideSessionPreferences(activity(MoreSettingsSecure::class.java)) + + preferences.edit().putBoolean(CHANGED_SETTING, true).commit() + + assertTrue(preferences.getBoolean(CHANGED_SETTING, false)) + assertFalse(commonPreferences(context, ephemeral = false).contains(CHANGED_SETTING)) + } + + @Test + fun provideSessionPreferences_regularActivity_writesTheOwnersFile() { + val preferences = module.provideSessionPreferences(activity(MoreSettings::class.java)) + + preferences.edit().putBoolean(CHANGED_SETTING, true).commit() + + assertTrue(commonPreferences(context, ephemeral = false).getBoolean(CHANGED_SETTING, false)) + } + + @Test + fun provideModePreferences_secureActivity_keepsEveryModesWritesOutOfTheOwnersFiles() { + val preferences = module.provideModePreferences(activity(MoreSettingsSecure::class.java)) + + preferences.values.forEach { + it.value.edit().putBoolean(CHANGED_SETTING, true).commit() + } + + preferences.keys.forEach { + assertFalse( + it.name, + context.getSharedPreferences(it.name, Context.MODE_PRIVATE) + .contains(CHANGED_SETTING), + ) + } + } + + private fun activity(type: Class): T { + return Robolectric.buildActivity(type).get() + } + + companion object { + private const val CHANGED_SETTING = "a_setting_the_session_changed" + } +} 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 000000000..2fe1e13fb --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/util/EphemeralSharedPrefsTest.kt @@ -0,0 +1,92 @@ +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: a secure entry point is handed one of these instead of the file, cloned from + * it but backed by memory. + * + * 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(): SharedPreferences { + return EphemeralSharedPrefs.copyOf(context = context, name = PREFS_NAME) + } + + @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")) + } + + /** Each copy starts from the file again, so the session has to be handed one and keep it. */ + @Test + fun eachCopyStartsFromWhatIsStored() { + persistentPrefs().edit().putInt("photoQuality", 85).commit() + + ephemeralPrefs().edit().putInt("photoQuality", 42).commit() + + assertEquals(85, ephemeralPrefs().getInt("photoQuality", -1)) + } + + private companion object { + // COMMON_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 000000000..3f67ea5ac --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=35 diff --git a/build.gradle.kts b/build.gradle.kts index 3d33ced36..0ccb29beb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,16 +1,56 @@ +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) } } +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 000000000..2656da8b9 --- /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 bd3ba351e..2305553cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,20 @@ [versions] agp = "9.3.1" +hilt = "2.60.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 = "1.6.1" +camerax = { strictly = "1.6.1" } constraintlayout = "2.2.2" coreKtx = "1.19.0" +# 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" material = "1.14.0" zxing = "3.5.4" @@ -14,11 +22,16 @@ 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" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +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" } material = { module = "com.google.android.material:material", version.ref = "material" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } @@ -33,7 +46,10 @@ 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" } +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" } @@ -49,3 +65,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" } +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 3bc86b399..ef9a5eb7a 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -127,6 +127,20 @@ + + + + + + + + + + + + + + @@ -1076,6 +1090,17 @@ + + + + + + + + + + + @@ -1735,6 +1760,17 @@ + + + + + + + + + + + @@ -1926,6 +1962,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2762,6 +2845,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2877,6 +3052,20 @@ + + + + + + + + + + + + + + @@ -2905,6 +3094,11 @@ + + + + + @@ -3065,13 +3259,126 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3128,6 +3435,23 @@ + + + + + + + + + + + + + + + + + @@ -3187,6 +3511,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3223,6 +3575,17 @@ + + + + + + + + + + + @@ -3309,6 +3672,25 @@ + + + + + + + + + + + + + + + + + + + @@ -3324,6 +3706,11 @@ + + + + + @@ -3359,6 +3746,16 @@ + + + + + + + + + + @@ -3373,6 +3770,25 @@ + + + + + + + + + + + + + + + + + + + @@ -3401,6 +3817,20 @@ + + + + + + + + + + + + + + @@ -3462,6 +3892,20 @@ + + + + + + + + + + + + + + @@ -3510,6 +3954,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3540,6 +4018,11 @@ + + + + + @@ -3550,11 +4033,26 @@ + + + + + + + + + + + + + + + @@ -3613,6 +4111,20 @@ + + + + + + + + + + + + + + @@ -3923,6 +4435,25 @@ + + + + + + + + + + + + + + + + + + + @@ -3956,87 +4487,373 @@ - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - - - + + - - - + + + - - - - + + - - + + - - + + - - + + - - - + + + - - - - + + - - - - + + - - - - + + + + + - - - + + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4111,11 +4928,601 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4875,6 +6282,20 @@ + + + + + + + + + + + + + + @@ -5289,6 +6710,20 @@ + + + + + + + + + + + + + + @@ -5303,6 +6738,20 @@ + + + + + + + + + + + + + + @@ -5314,6 +6763,23 @@ + + + + + + + + + + + + + + + + + @@ -5331,6 +6797,20 @@ + + + + + + + + + + + + + + @@ -5410,6 +6890,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5477,6 +6990,23 @@ + + + + + + + + + + + + + + + + + @@ -5519,6 +7049,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5667,6 +7230,11 @@ + + + + + @@ -5801,6 +7369,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5896,6 +7492,20 @@ + + + + + + + + + + + + + + @@ -6362,6 +7972,20 @@ + + + + + + + + + + + + + + @@ -6443,6 +8067,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6493,6 +8145,17 @@ + + + + + + + + + + + @@ -6526,6 +8189,17 @@ + + + + + + + + + + + @@ -6615,6 +8289,23 @@ + + + + + + + + + + + + + + + + + @@ -6628,8 +8319,22 @@ - - + + + + + + + + + + + + + + + + @@ -6659,6 +8364,9 @@ + + + @@ -6683,6 +8391,23 @@ + + + + + + + + + + + + + + + + + @@ -6703,6 +8428,23 @@ + + + + + + + + + + + + + + + + + @@ -6713,6 +8455,9 @@ + + + @@ -6833,6 +8578,17 @@ + + + + + + + + + + + @@ -6858,6 +8614,17 @@ + + + + + + + + + + + @@ -6888,6 +8655,22 @@ + + + + + + + + + + + + + + + + @@ -6930,6 +8713,20 @@ + + + + + + + + + + + + + + @@ -6941,6 +8738,17 @@ + + + + + + + + + + + @@ -6952,6 +8760,17 @@ + + + + + + + + + + + @@ -6980,6 +8799,17 @@ + + + + + + + + + + + @@ -6994,6 +8824,20 @@ + + + + + + + + + + + + + + @@ -7199,6 +9043,23 @@ + + + + + + + + + + + + + + + + + @@ -7242,9 +9103,6 @@ - - - @@ -7252,6 +9110,16 @@ + + + + + + + + + + @@ -7282,6 +9150,17 @@ + + + + + + + + + + + @@ -7345,6 +9224,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7421,6 +9331,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7497,6 +9563,20 @@ + + + + + + + + + + + + + + @@ -7536,6 +9616,20 @@ + + + + + + + + + + + + + + @@ -7550,6 +9644,20 @@ + + + + + + + + + + + + + + @@ -7589,6 +9697,212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7603,11 +9917,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7632,5 +9984,19 @@ + + + + + + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 21093c15a..2aac19dde 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,10 @@ +@file:Suppress("UnstableApiUsage") + pluginManagement { repositories { google() mavenCentral() + gradlePluginPortal() } } dependencyResolutionManagement {