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..a528bda08 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,450 @@ +# 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`. + **Preferences are the exception, and they are opened and chosen in two different places.** The + owner's storage is opened once, in a `SingletonComponent` module under its own qualifier — a + process may hold one handle per file, so a second one is not an option to have. Which of them a + session is given is a `@Provides` in an `ActivityComponent` module, built on `@ActivityContext` + and `@ActivityScoped` rather than `@Reusable`: it selects between the owner's storage and a + throwaway copy of it, from the entry point it was given, and nowhere else — a session that has to + ask twice can be handed a second copy, and everything it changed in the first is lost. Nothing + below reads the entry point to find out which it got. Storage that stays durable whatever the + session — what the app has captured, as opposed to what the owner configured — is separate, + provided under its own qualifier, so that "this outlives the lockscreen session" is a binding a + reviewer can see rather than a branch inside a store. + +**Unidirectional data flow per screen** (`ui//screen/`): + +- 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..4539d89b2 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,65 @@ if (useKeystoreProperties) { plugins { alias(libs.plugins.android.application) + alias(libs.plugins.detekt) + alias(libs.plugins.hilt) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +detekt { + 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 +151,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 +167,22 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.androidx.datastore) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.bundles.camerax) implementation(libs.zxing.core) + 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..0abc51754 --- /dev/null +++ b/app/config/ktlint/baseline.xml @@ -0,0 +1,839 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml new file mode 100644 index 000000000..c25cdc152 --- /dev/null +++ b/app/detekt-baseline-debug.xml @@ -0,0 +1,215 @@ + + + + + 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: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() + 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: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: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: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") + 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: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$private fun settleDuration: Long + 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$fun prefetchLastFrame + 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: ExecutionException + SwallowedException:CamConfig.kt:CamConfig$e: IllegalArgumentException + 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: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: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$imageCapture!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$videoCapture!! + UnsafeCallOnNullableType:CapturedItems.kt:CapturedItem.Companion.<no name provided>$source.readString()!! + 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: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/BottomTabLayoutRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt index 4ca9c716c..6ae24426a 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule +import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.ui.BottomTabLayout import app.grapheneos.camera.ui.activities.MainActivity import org.junit.Assert.assertEquals diff --git a/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt index 784cbdc75..810575aca 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..0d276c3e5 --- /dev/null +++ b/app/src/androidTest/java/app/grapheneos/camera/EntryPointContractTest.kt @@ -0,0 +1,192 @@ +package app.grapheneos.camera + +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import app.grapheneos.camera.ui.activities.SecureActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EntryPointContractTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val packageManager: PackageManager = context.packageManager + + private fun activityInfoFor(action: String): ActivityInfo { + val intent = Intent(action).setPackage(context.packageName) + val matches = packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL) + + assertEquals( + "Exactly one component in this app must answer $action, but got" + + " ${matches.map { it.activityInfo.name }}", + 1, + matches.size, + ) + return matches.single().activityInfo + } + + private fun isSecureActivity(info: ActivityInfo): Boolean { + return SecureActivity::class.java.isAssignableFrom(Class.forName(info.name)) + } + + private fun assertHandledBy( + action: String, + expectedComponent: String, + ) { + assertEquals( + "$action must be handled by $expectedComponent", + "$PACKAGE.$expectedComponent", + activityInfoFor(action).name, + ) + } + + private fun assertIsLockscreenEntryPoint( + action: String, + expectedAffinity: String, + ) { + val info = activityInfoFor(action) + + assertTrue( + "${info.name} must show over the keyguard, or $action does nothing on a locked" + + " phone", + info.flags and FLAG_SHOW_WHEN_LOCKED != 0, + ) + assertTrue( + "${info.name} must be excluded from recents, or what a locked session captured is" + + " listed to whoever picks the phone up next", + info.flags and ActivityInfo.FLAG_EXCLUDE_FROM_RECENTS != 0, + ) + + assertTrue( + "${info.name} must keep its own taskAffinity ending in" + + " .ui.activities.$expectedAffinity, or the locked session can surface the" + + " unlocked task, but it is ${info.taskAffinity}", + info.taskAffinity.orEmpty().endsWith(".ui.activities.$expectedAffinity"), + ) + + assertTrue( + "${info.name} must implement SecureActivity: it is what the preferences binding" + + " branches on, and without it a lockscreen session writes the owner's store", + isSecureActivity(info), + ) + } + + @Test + fun stillImageCameraIsAnAliasOntoTheMainActivity() { + val info = activityInfoFor("android.media.action.STILL_IMAGE_CAMERA") + + assertEquals("$PACKAGE.ui.activities.CameraLauncher", info.name) + assertEquals("$PACKAGE.ui.activities.MainActivity", info.targetActivity) + } + + @Test + fun videoCameraLaunchesTheVideoOnlyActivity() { + assertHandledBy("android.media.action.VIDEO_CAMERA", "ui.activities.VideoOnlyActivity") + } + + @Test + fun imageCaptureLaunchesTheCaptureActivity() { + assertHandledBy("android.media.action.IMAGE_CAPTURE", "ui.activities.CaptureActivity") + } + + @Test + fun videoCaptureLaunchesTheVideoCaptureActivity() { + assertHandledBy( + "android.media.action.VIDEO_CAPTURE", + "ui.activities.VideoCaptureActivity", + ) + } + + @Test + fun secureStillImageCameraLaunchesTheSecureMainActivity() { + assertHandledBy( + "android.media.action.STILL_IMAGE_CAMERA_SECURE", + "ui.activities.SecureMainActivity", + ) + } + + @Test + fun secureImageCaptureLaunchesTheSecureCaptureActivity() { + assertHandledBy( + "android.media.action.IMAGE_CAPTURE_SECURE", + "ui.activities.SecureCaptureActivity", + ) + } + + @Test + fun secureStillImageCameraIsALockscreenEntryPoint() { + assertIsLockscreenEntryPoint( + action = "android.media.action.STILL_IMAGE_CAMERA_SECURE", + expectedAffinity = "SecureMainActivity", + ) + } + + @Test + fun secureImageCaptureIsALockscreenEntryPoint() { + assertIsLockscreenEntryPoint( + action = "android.media.action.IMAGE_CAPTURE_SECURE", + expectedAffinity = "SecureCaptureActivity", + ) + } + + @Test + fun theUnlockedEntryPointsDoNotShowOverTheKeyguard() { + // The mirror of the assertions above: were every activity showWhenLocked, they would + // pass while the distinction they exist to protect had been erased. + listOf( + "android.media.action.VIDEO_CAMERA", + "android.media.action.IMAGE_CAPTURE", + "android.media.action.VIDEO_CAPTURE", + ).forEach { action -> + val info = activityInfoFor(action) + + assertEquals( + "${info.name} answers the non-secure $action and must not show over the" + + " keyguard", + 0, + info.flags and FLAG_SHOW_WHEN_LOCKED, + ) + assertFalse( + "${info.name} answers the non-secure $action and must not be a SecureActivity," + + " or it is handed a throwaway copy of the preferences it is meant to keep", + isSecureActivity(info), + ) + } + } + + @Test + fun qrTileKeepsTheNameAndFlagsSystemUiDependsOn() { + val info = packageManager.getActivityInfo( + ComponentName(context.packageName, "$PACKAGE.ui.activities.QrTile"), + PackageManager.MATCH_ALL, + ) + + assertTrue( + "QrTile must stay exported — SystemUI starts it from outside the app", + info.exported, + ) + assertTrue( + "QrTile must show over the keyguard; it is a lockscreen shortcut target", + info.flags and FLAG_SHOW_WHEN_LOCKED != 0, + ) + assertTrue( + "QrTile must be excluded from recents", + info.flags and ActivityInfo.FLAG_EXCLUDE_FROM_RECENTS != 0, + ) + assertNull("QrTile is a real activity, not an alias", info.targetActivity) + } + + private companion object { + const val PACKAGE = "app.grapheneos.camera" + + // ActivityInfo.FLAG_SHOW_WHEN_LOCKED is @hide + const val FLAG_SHOW_WHEN_LOCKED = 0x800000 + } +} diff --git a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt index 293720fc5..f5a7ce07a 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt @@ -4,6 +4,7 @@ import android.Manifest import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule +import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.ui.activities.MainActivity import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals @@ -156,11 +157,11 @@ class ModeSwitchLatencyRegressionTest { } } - // Both halves are needed: the setter behind updateVideoQuality() persists whichever quality - // the spinner is showing, not the one it is passed. + // Both halves are needed: setSelection() only posts its callback, so the quality is applied + // here rather than a looper pass later. private fun selectVideoQuality(activity: MainActivity, position: Int) { val dialog = activity.settingsDialog dialog.videoQualitySpinner.setSelection(position) - dialog.updateVideoQuality(dialog.videoQualitySpinner.getItemAtPosition(position) as String) + dialog.updateVideoQuality(dialog.videoQualities[position]) } } diff --git a/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt index 1893d21e5..32bd06833 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt @@ -1,14 +1,17 @@ package app.grapheneos.camera +import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.provider.DocumentsContract import android.provider.MediaStore import androidx.test.ext.junit.runners.AndroidJUnit4 -import app.grapheneos.camera.CamConfig.SettingValues -import app.grapheneos.camera.util.EphemeralSharedPrefs -import app.grapheneos.camera.util.edit +import androidx.test.platform.app.InstrumentationRegistry +import app.grapheneos.camera.data.core.store.InMemoryDataStore +import app.grapheneos.camera.data.media.repository.CapturedItemRepositoryImpl +import app.grapheneos.camera.data.media.store.MediaPrefs +import app.grapheneos.camera.data.media.store.StoragePrefs +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -31,43 +34,47 @@ class SafTreeGrantsRegressionTest { return DocumentsContract.buildTreeDocumentUri(authority, "primary:$name") } - // Only an in-memory SharedPreferences here; what it holds is the real tracked list. - private fun prefs() = EphemeralSharedPrefs(Build.VERSION.SDK_INT) + private val context: Context = InstrumentationRegistry + .getInstrumentation() + .targetContext + .applicationContext - /** What CamConfig.storageLocation records when the user picks a directory. */ - private fun pickStorageLocation(prefs: EphemeralSharedPrefs, treeUri: Uri) { - val current = prefs.getString( - SettingValues.Key.STORAGE_LOCATION, SettingValues.Default.STORAGE_LOCATION - )!! - if (current != SettingValues.Default.STORAGE_LOCATION) { - CapturedItems.savePreviousSafTree(Uri.parse(current), prefs) - } - prefs.edit { - putString(SettingValues.Key.STORAGE_LOCATION, treeUri.toString()) - } + private fun session(): CapturedItemRepositoryImpl { + return CapturedItemRepositoryImpl( + storagePrefs = InMemoryDataStore(StoragePrefs()), + mediaPrefs = InMemoryDataStore(MediaPrefs()), + context = context, + ) + } + + /** The write the app makes when the user picks a directory to save captures in. */ + private fun pickStorageLocation(session: CapturedItemRepositoryImpl, treeUri: Uri) { + runBlocking { session.setStorageLocation(treeUri.toString()) } } /** The regression itself: the directory pushed off the tracked list is the one to release. */ @Test fun theTreeThatFallsOffTheTrackedListIsReleased() { - val prefs = prefs() + val session = session() val picked = (0..CapturedItems.MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES + 1) .map { tree("dir$it") } - picked.forEach { pickStorageLocation(prefs, it) } + picked.forEach { pickStorageLocation(session, it) } - val tracked = CapturedItems.getSafTrees(prefs) + val tracked = runBlocking { session.trackedSafTrees() } assertEquals(CapturedItems.MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES + 1, tracked.size) picked.take(picked.size - tracked.size).forEach { assertEquals( it.toString(), readAndWrite, - CapturedItems.safTreeFlagsToRelease(it, true, true, tracked), + CapturedItems.safTreeFlagsToRelease(it, isRead = true, isWrite = true, tracked), ) } tracked.forEach { assertEquals( - it.toString(), 0, CapturedItems.safTreeFlagsToRelease(it, true, true, tracked) + it.toString(), + 0, + CapturedItems.safTreeFlagsToRelease(it, isRead = true, isWrite = true, tracked), ) } } @@ -75,14 +82,30 @@ class SafTreeGrantsRegressionTest { /** A directory the app still lists keeps its grant, whether it is the current one or a past one. */ @Test fun trackedTreesKeepTheirGrants() { - val prefs = prefs() - pickStorageLocation(prefs, tree("previous")) - pickStorageLocation(prefs, tree("current")) + val session = session() + pickStorageLocation(session, tree("previous")) + pickStorageLocation(session, tree("current")) - val tracked = CapturedItems.getSafTrees(prefs) + val tracked = runBlocking { session.trackedSafTrees() } assertEquals(listOf(tree("current"), tree("previous")), tracked) - assertEquals(0, CapturedItems.safTreeFlagsToRelease(tree("current"), true, true, tracked)) - assertEquals(0, CapturedItems.safTreeFlagsToRelease(tree("previous"), true, true, tracked)) + assertEquals( + 0, + CapturedItems.safTreeFlagsToRelease( + tree("current"), + isRead = true, + isWrite = true, + tracked, + ), + ) + assertEquals( + 0, + CapturedItems.safTreeFlagsToRelease( + tree("previous"), + isRead = true, + isWrite = true, + tracked, + ), + ) } /** Persisted grants that are not trees belong to some other feature, not to storage locations. */ @@ -91,10 +114,16 @@ class SafTreeGrantsRegressionTest { val tracked = emptyList() val document = DocumentsContract.buildDocumentUri(authority, "primary:DCIM/IMG_1.jpg") - assertEquals(0, CapturedItems.safTreeFlagsToRelease(document, true, true, tracked)) + assertEquals( + 0, + CapturedItems.safTreeFlagsToRelease(document, isRead = true, isWrite = true, tracked), + ) val mediaStore = MediaStore.Images.Media.EXTERNAL_CONTENT_URI - assertEquals(0, CapturedItems.safTreeFlagsToRelease(mediaStore, true, true, tracked)) + assertEquals( + 0, + CapturedItems.safTreeFlagsToRelease(mediaStore, isRead = true, isWrite = true, tracked), + ) } /** The release covers exactly the modes the grant holds, and a grant holding none is skipped. */ @@ -105,15 +134,24 @@ class SafTreeGrantsRegressionTest { assertEquals( Intent.FLAG_GRANT_READ_URI_PERMISSION, - CapturedItems.safTreeFlagsToRelease(untracked, true, false, tracked), + CapturedItems.safTreeFlagsToRelease(untracked, isRead = true, isWrite = false, tracked), ) assertEquals( Intent.FLAG_GRANT_WRITE_URI_PERMISSION, - CapturedItems.safTreeFlagsToRelease(untracked, false, true, tracked), + CapturedItems.safTreeFlagsToRelease(untracked, isRead = false, isWrite = true, tracked), + ) + assertEquals( + readAndWrite, + CapturedItems.safTreeFlagsToRelease(untracked, isRead = true, isWrite = true, tracked), ) assertEquals( - readAndWrite, CapturedItems.safTreeFlagsToRelease(untracked, true, true, tracked) + 0, + CapturedItems.safTreeFlagsToRelease( + untracked, + isRead = false, + isWrite = false, + tracked, + ), ) - assertEquals(0, CapturedItems.safTreeFlagsToRelease(untracked, false, false, tracked)) } } diff --git a/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt new file mode 100644 index 000000000..d00eff719 --- /dev/null +++ b/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt @@ -0,0 +1,167 @@ +package app.grapheneos.camera + +import android.Manifest +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.di.preferences.DurableSettingsPrefsEntryPoint +import app.grapheneos.camera.ui.activities.MainActivity +import app.grapheneos.camera.ui.activities.SecureMainActivity +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.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 owner's store — or handed it a fresh copy on every lookup, so its own changes + * were silently dropped — would satisfy every other test in this suite. + */ +@RunWith(AndroidJUnit4::class) +class SecurePrefsIsolationTest { + @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 asTheOwner(block: (SettingsRepository) -> Unit) { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> block(activity.settingsRepository) } + } + } + + private val durableSettings: DataStore by lazy { + EntryPointAccessors + .fromApplication(context, DurableSettingsPrefsEntryPoint::class.java) + .settingsPrefs() + } + + private lateinit var ownersSettings: SettingsPrefs + + private fun stored(): SettingsPrefs { + return runBlocking { durableSettings.data.first() } + } + + @Before + fun rememberOwnersSettings() { + ownersSettings = stored() + } + + @After + fun restoreOwnersSettings() { + runBlocking { durableSettings.updateData { ownersSettings } } + } + + @Test + fun writesInASecureSessionDoNotChangeThePersistentPrefs() { + asTheOwner { repository -> + runBlocking { repository.update { it.copy(photoQuality = OWNERS_QUALITY) } } + } + + ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + runBlocking { + activity.settingsRepository.update { it.copy(photoQuality = SESSIONS_QUALITY) } + } + } + } + + assertEquals( + "A secure session wrote through to the owner's preferences", + OWNERS_QUALITY, + stored().common.photoQuality, + ) + } + + @Test + fun aSecureSessionStillReadsTheOwnersSettings() { + asTheOwner { repository -> + runBlocking { repository.update { it.copy(photoQuality = OWNERS_QUALITY) } } + } + + ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + assertEquals( + "The isolation must be one-way: a lockscreen session still honours the" + + " settings the owner chose", + OWNERS_QUALITY, + runBlocking { activity.settingsRepository.settings.first() }.photoQuality, + ) + } + } + } + + /** A session handed a fresh copy on every lookup would read the owner's value back. */ + @Test + fun aSecureSessionKeepsItsModeSettingsToItselfAndThenKeepsThem() { + asTheOwner { repository -> + runBlocking { + repository.selectMode(mode = MODE, isFrontFacing = false) + repository.setGeoTagging(false) + } + } + + ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val repository = activity.settingsRepository + + val slotted = runBlocking { + repository.selectMode(mode = MODE, isFrontFacing = false) + repository.setGeoTagging(true) + repository.selectMode(mode = MODE, isFrontFacing = false) + } + + assertTrue( + "The session lost its own mode-scoped write, so it was handed a second copy" + + " of the owner's preferences instead of the one it had been changing", + slotted.geoTagging, + ) + } + } + + assertEquals( + "A secure session wrote through to the owner's mode preferences", + false, + stored().modes[MODE.name]?.geoTagging, + ) + } + + @Test + fun theRegularActivityDoesWriteThePersistentPrefs() { + // The mirror of the tests above: if this ever fails, they would pass for the wrong + // reason — because nothing writes preferences at all. + asTheOwner { repository -> + runBlocking { repository.update { it.copy(photoQuality = SESSIONS_QUALITY) } } + } + + assertEquals(SESSIONS_QUALITY, stored().common.photoQuality) + } + + 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/debug/java/app/grapheneos/camera/di/preferences/DurableSettingsPrefsEntryPoint.kt b/app/src/debug/java/app/grapheneos/camera/di/preferences/DurableSettingsPrefsEntryPoint.kt new file mode 100644 index 000000000..559d54dd8 --- /dev/null +++ b/app/src/debug/java/app/grapheneos/camera/di/preferences/DurableSettingsPrefsEntryPoint.kt @@ -0,0 +1,16 @@ +package app.grapheneos.camera.di.preferences + +import androidx.datastore.core.DataStore +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.di.core.DurablePreferences +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@EntryPoint +@InstallIn(SingletonComponent::class) +internal interface DurableSettingsPrefsEntryPoint { + + @DurablePreferences + fun settingsPrefs(): DataStore +} diff --git a/app/src/main/java/app/grapheneos/camera/App.kt b/app/src/main/java/app/grapheneos/camera/App.kt index 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 7251ed716..b5f3163fe 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 @@ -52,6 +50,15 @@ 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.ui.videoQualityTitle +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 @@ -61,125 +68,24 @@ import app.grapheneos.camera.ui.activities.SecureMainActivity import app.grapheneos.camera.ui.activities.VideoCaptureActivity import app.grapheneos.camera.ui.activities.VideoOnlyActivity import app.grapheneos.camera.ui.showIgnoringShortEdgeMode -import app.grapheneos.camera.util.edit import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.zxing.BarcodeFormat import java.util.concurrent.ExecutionException import java.util.concurrent.Executors import kotlin.concurrent.thread - -// 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.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking @SuppressLint("UnsafeOptInUsageError") -class CamConfig(private val mActivity: MainActivity) { - - enum class GridType { - NONE, - THREE_BY_THREE, - FOUR_BY_FOUR, - GOLDEN_RATIO - } - - object SettingValues { - - object Key { - const val SELF_ILLUMINATION = "self_illumination" - const val GEO_TAGGING = "geo_tagging" - const val FLASH_MODE = "flash_mode" - const val GRID = "grid" - - // obsolete, split into WAIT_FOR_FOCUS_LOCK and PHOTO_QUALITY - const val EMPHASIS_ON_QUALITY = "emphasis_on_quality" - const val FOCUS_TIMEOUT = "focus_timeout" - const val VIDEO_QUALITY = "video_quality" - const val ASPECT_RATIO = "aspect_ratio" - const val INCLUDE_AUDIO = "include_audio" - const val ENABLE_EIS = "enable_eis" - const val SCAN = "scan" - const val SCAN_ALL_CODES = "scan_all_codes" - const val SAVE_IMAGE_AS_PREVIEW = "save_image_as_preview" - const val SAVE_VIDEO_AS_PREVIEW = "save_video_as_preview" - - const val STORAGE_LOCATION = "storage_location" - const val PREVIOUS_SAF_TREES = "previous_saf_trees" - - const val LAST_CAPTURED_ITEM_TYPE = "last_captured_item_type" - const val LAST_CAPTURED_ITEM_DATE_STRING = "last_captured_item_date_string" - const val LAST_CAPTURED_ITEM_URI = "last_captured_item_uri" - - const val PHOTO_QUALITY = "photo_quality" - - const val REMOVE_EXIF_AFTER_CAPTURE = "remove_exif_after_capture" - - const val GYROSCOPE_SUGGESTIONS = "gyroscope_suggestions" - - const val CAMERA_SOUNDS = "camera_sounds" - - const val ENABLE_ZSL = "enable_zsl" - - const val SELECT_HIGHEST_RESOLUTION = "select_highest_resolution" - - const val WAIT_FOR_FOCUS_LOCK = "wait_for_focus_lock" - - const val SELF_TIMER_DURATION = "self_timer_duration" - } - - object Default { - - val GRID_TYPE = GridType.NONE - const val GRID_TYPE_INDEX = 0 - - const val ASPECT_RATIO = AspectRatio.RATIO_4_3 - - val VIDEO_QUALITY = Quality.HIGHEST - - const val SELF_ILLUMINATION = false - - const val GEO_TAGGING = false - - const val FLASH_MODE = ImageCapture.FLASH_MODE_OFF - - const val FOCUS_TIMEOUT = "5s" - - const val INCLUDE_AUDIO = true - - const val ENABLE_EIS = true - - const val SCAN_ALL_CODES = false - - const val SAVE_IMAGE_AS_PREVIEW = true - - const val SAVE_VIDEO_AS_PREVIEW = true - - const val STORAGE_LOCATION = "" - - const val PHOTO_QUALITY = 95 - - const val REMOVE_EXIF_AFTER_CAPTURE = true - - const val GYROSCOPE_SUGGESTIONS = false - - const val CAMERA_SOUNDS = true - - const val ENABLE_ZSL = false - - const val SELECT_HIGHEST_RESOLUTION = false - - const val WAIT_FOR_FOCUS_LOCK = false - - const val SELF_TIMER_DURATION = 0 - } - } +class CamConfig( + private val mActivity: MainActivity, + private val settingsRepository: SettingsRepository, + private val capturedItemRepository: CapturedItemRepository, +) { companion object { private const val TAG = "CamConfig" @@ -206,8 +112,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() @@ -307,36 +211,45 @@ class CamConfig(private val mActivity: MainActivity) { @set:VisibleForTesting var mPlayer = TunePlayer(mActivity) - // note that Activities which implement SecureActivity interface (meaning they are accessible - // from the lock screen) are forced to override getSharedPreferences() - // and return an instance of in-memory EphemeralSharedPrefs, which are based on "real" prefs, - // but never modify them - val commonPref: SharedPreferences = - mActivity.getSharedPreferences(COMMON_SHARED_PREFS_NAME, Context.MODE_PRIVATE) - private lateinit var modePref: SharedPreferences + private var settings: CameraSettings = runBlocking { settingsRepository.settings.first() } + + private var currentStorageLocation: String = runBlocking { + capturedItemRepository.storageLocation.first() + } + + private var modeSettings: ModeSettings = ModeSettings() + + private val preferencesScope = CoroutineScope(Dispatchers.Main.immediate) var lastCapturedItem: CapturedItem? = null init { - if (mActivity !is SecureActivity) { - CapturedItems.init(mActivity, this) - fetchLastCapturedItemFromSharedPrefs() + preferencesScope.launch(Dispatchers.Main.immediate) { + settingsRepository.settings.collect { settings = it } } - } - fun fetchLastCapturedItemFromSharedPrefs() { - val type = commonPref.getInt(SettingValues.Key.LAST_CAPTURED_ITEM_TYPE, -1) - val dateStr = commonPref.getString(SettingValues.Key.LAST_CAPTURED_ITEM_DATE_STRING, null) - val uri = commonPref.getString(SettingValues.Key.LAST_CAPTURED_ITEM_URI, null) + preferencesScope.launch(Dispatchers.Main.immediate) { + capturedItemRepository.storageLocation.collect { currentStorageLocation = it } + } - var item: CapturedItem? = null - if (dateStr != null && uri != null) { - val skip = type == ITEM_TYPE_IMAGE && mActivity is VideoOnlyActivity - if (!skip) { - item = CapturedItem(type, dateStr, Uri.parse(uri)) + if (mActivity !is SecureActivity) { + runBlocking { + capturedItemRepository.migrateStoredCaptures(::updateLastCapturedItem) + capturedItemRepository.releaseUntrackedSafTrees() } + fetchLastCapturedItem() } - lastCapturedItem = item + } + + fun onDestroy() { + preferencesScope.cancel() + } + + fun fetchLastCapturedItem() { + val item = runBlocking { capturedItemRepository.lastCapturedItem() } + val skip = item?.type == ITEM_TYPE_IMAGE && mActivity is VideoOnlyActivity + + lastCapturedItem = if (skip) null else item } @@ -387,17 +300,14 @@ class CamConfig(private val mActivity: MainActivity) { } else -> { - commonPref.getInt( - SettingValues.Key.ASPECT_RATIO, - SettingValues.Default.ASPECT_RATIO - ) + settings.aspectRatio } } } set(value) { - val editor = commonPref.edit() - editor.putInt(SettingValues.Key.ASPECT_RATIO, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(aspectRatio = value) } + } } var lensFacing = DEFAULT_LENS_FACING @@ -406,101 +316,81 @@ class CamConfig(private val mActivity: MainActivity) { .requireLensFacing(DEFAULT_LENS_FACING) .build() - var gridType: GridType = SettingValues.Default.GRID_TYPE + var gridType: GridType + get() { + return settings.gridType + } set(value) { - val editor = commonPref.edit() - editor.putInt(SettingValues.Key.GRID, GridType.values().indexOf(value)) - editor.apply() - - field = value + settings = runBlocking { + settingsRepository.update { it.copy(gridType = value) } + } } - var videoQuality: Quality = SettingValues.Default.VIDEO_QUALITY + var videoQuality: Quality get() { - return if (modePref.contains(videoQualityKey)) { - mActivity.settingsDialog.titleToQuality( - modePref.getString(videoQualityKey, "")!! - ) - } else { - SettingValues.Default.VIDEO_QUALITY - } + return modeSettings.videoQuality } set(value) { - val option = mActivity.settingsDialog.videoQualitySpinner.selectedItem as String - - modePref.edit { - putString(videoQualityKey, option) - } - - field = value + runBlocking { + settingsRepository.setVideoQuality(value) + }?.let { modeSettings = it } } - private val videoQualityKey: String - get() { + var flashMode: Int = SettingsDefaults.FLASH_MODE + private set - val pf = if (lensFacing == CameraSelector.LENS_FACING_FRONT) { - "FRONT" - } else { - "BACK" - } + fun setFlashMode(value: Int) { + runBlocking { + settingsRepository.setFlashMode(value) + }?.let { modeSettings = it } - return "${SettingValues.Key.VIDEO_QUALITY}_$pf" - } + applyFlashMode(value) + } - var flashMode: Int - get() = if (imageCapture != null) imageCapture!!.flashMode else - SettingValues.Default.FLASH_MODE - set(flashMode) { + private fun applyFlashMode(value: Int) { + flashMode = value + imageCapture?.flashMode = value + mActivity.settingsDialog.updateFlashMode() + } - if (::modePref.isInitialized) { - modePref.edit { - putInt(SettingValues.Key.FLASH_MODE, flashMode) - } + var focusTimeout: Long + get() { + return settings.focusTimeoutSeconds + } + set(value) { + settings = runBlocking { + settingsRepository.update { it.copy(focusTimeoutSeconds = value) } } - - imageCapture?.flashMode = flashMode - mActivity.settingsDialog.updateFlashMode() } - var focusTimeout = 5L + var selfTimerDuration: Int + get() { + return settings.selfTimerDurationSeconds + } set(value) { - val option = if (value == 0L) { - "Off" - } else { - "${value}s" + settings = runBlocking { + settingsRepository.update { it.copy(selfTimerDurationSeconds = value) } } - - val editor = commonPref.edit() - editor.putString(SettingValues.Key.FOCUS_TIMEOUT, option) - editor.apply() - - field = value } var enableCameraSounds: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.CAMERA_SOUNDS, - SettingValues.Default.CAMERA_SOUNDS - ) + return settings.enableCameraSounds } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.CAMERA_SOUNDS, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(enableCameraSounds = value) } + } } var scanAllCodes: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.SCAN_ALL_CODES, - SettingValues.Default.SCAN_ALL_CODES - ) + return settings.scanAllCodes } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.SCAN_ALL_CODES, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(scanAllCodes = value) } + } if (isQRMode) { if (value) { @@ -521,133 +411,99 @@ class CamConfig(private val mActivity: MainActivity) { var includeAudio: Boolean get() { - return mActivity.settingsDialog.includeAudioToggle.isChecked + return settings.includeAudio } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.INCLUDE_AUDIO, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(includeAudio = value) } + } mActivity.settingsDialog.includeAudioToggle.isChecked = value } var enableEIS: Boolean get() { - return mActivity.settingsDialog.enableEISToggle.isChecked + return settings.enableEis } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.ENABLE_EIS, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(enableEis = value) } + } mActivity.settingsDialog.enableEISToggle.isChecked = value } var enableZsl: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.ENABLE_ZSL, - SettingValues.Default.ENABLE_ZSL - ) + return settings.enableZsl } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.ENABLE_ZSL, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(enableZsl = value) } + } } var saveImageAsPreviewed: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.SAVE_IMAGE_AS_PREVIEW, - SettingValues.Default.SAVE_IMAGE_AS_PREVIEW - ) + return settings.saveImageAsPreviewed } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.SAVE_IMAGE_AS_PREVIEW, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(saveImageAsPreviewed = value) } + } } var saveVideoAsPreviewed: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.SAVE_VIDEO_AS_PREVIEW, - SettingValues.Default.SAVE_VIDEO_AS_PREVIEW - ) + return settings.saveVideoAsPreviewed } set(value) { - val editor = commonPref.edit() - editor.putBoolean(SettingValues.Key.SAVE_VIDEO_AS_PREVIEW, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(saveVideoAsPreviewed = value) } + } } var storageLocation: String get() { - return commonPref.getString( - SettingValues.Key.STORAGE_LOCATION, - SettingValues.Default.STORAGE_LOCATION - )!! + return currentStorageLocation } set(value) { - val cur = storageLocation - if (cur != SettingValues.Default.STORAGE_LOCATION) { - CapturedItems.savePreviousSafTree(Uri.parse(cur), commonPref) + runBlocking { + capturedItemRepository.setStorageLocation(value) + capturedItemRepository.releaseUntrackedSafTrees() } - val editor = commonPref.edit() - editor.putString(SettingValues.Key.STORAGE_LOCATION, value) - editor.apply() - - // Strictly after the write: the tree being picked only becomes tracked once it is the - // stored location, and re-picking a tree that savePreviousSafTree() just pushed off the - // tail of the tracked list would otherwise have its grant revoked out from under it. - CapturedItems.releaseUntrackedSafTrees(mActivity, commonPref) + currentStorageLocation = value } var photoQuality: Int get() { - return commonPref.getInt( - SettingValues.Key.PHOTO_QUALITY, - SettingValues.Default.PHOTO_QUALITY - ) + return settings.photoQuality } set(value) { - val editor = commonPref.edit() - editor.putInt(SettingValues.Key.PHOTO_QUALITY, value) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(photoQuality = value) } + } } var removeExifAfterCapture: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.REMOVE_EXIF_AFTER_CAPTURE, - SettingValues.Default.REMOVE_EXIF_AFTER_CAPTURE - ) + return settings.removeExifAfterCapture } set(value) { - val editor = commonPref.edit() - editor.putBoolean( - SettingValues.Key.REMOVE_EXIF_AFTER_CAPTURE, - value - ) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(removeExifAfterCapture = value) } + } } var gSuggestions: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.GYROSCOPE_SUGGESTIONS, - SettingValues.Default.GYROSCOPE_SUGGESTIONS - ) + return settings.gyroscopeSuggestions } set(value) { - val editor = commonPref.edit() - editor.putBoolean( - SettingValues.Key.GYROSCOPE_SUGGESTIONS, - value - ) - editor.apply() + settings = runBlocking { + settingsRepository.update { it.copy(gyroscopeSuggestions = value) } + } } val isZslSupported: Boolean by lazy { @@ -697,45 +553,24 @@ class CamConfig(private val mActivity: MainActivity) { return mActivity is CaptureActivity } - private fun saveLastCapturedItem(item: CapturedItem, editor: SharedPreferences.Editor) { - editor.putInt(SettingValues.Key.LAST_CAPTURED_ITEM_TYPE, item.type) - editor.putString(SettingValues.Key.LAST_CAPTURED_ITEM_DATE_STRING, item.dateString) - editor.putString(SettingValues.Key.LAST_CAPTURED_ITEM_URI, item.uri.toString()) - } - fun updateLastCapturedItem(item: CapturedItem) { - commonPref.edit { - saveLastCapturedItem(item, this) - } - - if (mActivity is SecureMainActivity) { - // previous call updated ephemeral SharedPreferences that won't be accessible by the - // "regular" MainActivity - mActivity.applicationContext.getSharedPreferences( - COMMON_SHARED_PREFS_NAME, - Context.MODE_PRIVATE - ).edit { - saveLastCapturedItem(item, this) - } - } + runBlocking { capturedItemRepository.saveLastCapturedItem(item) } lastCapturedItem = item } + // Session state rather than the stored value: geo-tagging is only ever on once the permission + // is actually granted, and reloadSettings() is what settles a stored "on" against that. Reading + // the preference back here would resurrect the very stale "on" the coercion exists to drop. var requireLocation: Boolean = false - get() { - return mActivity.settingsDialog.locToggle.isChecked - } set(value) { mActivity.locationCamConfigChanged(value) // A permission result is delivered before the first onResume of an activity the system - // recreated, so this can run before startCamera() has picked the prefs for a mode - if (::modePref.isInitialized) { - modePref.edit { - putBoolean(SettingValues.Key.GEO_TAGGING, value) - } - } + // recreated, so this can run before a mode has been slotted — see modeSettings. + runBlocking { + settingsRepository.setGeoTagging(value) + }?.let { modeSettings = it } mActivity.settingsDialog.locToggle.isChecked = value @@ -744,16 +579,13 @@ class CamConfig(private val mActivity: MainActivity) { var selfIlluminate: Boolean get() { - return modePref.getBoolean( - SettingValues.Key.SELF_ILLUMINATION, - SettingValues.Default.SELF_ILLUMINATION - ) - && lensFacing == CameraSelector.LENS_FACING_FRONT + return modeSettings.selfIllumination && + lensFacing == CameraSelector.LENS_FACING_FRONT } set(value) { - modePref.edit { - putBoolean(SettingValues.Key.SELF_ILLUMINATION, value) - } + runBlocking { + settingsRepository.setSelfIllumination(value) + }?.let { modeSettings = it } mActivity.settingsDialog.selfIlluminationToggle.isChecked = value mActivity.settingsDialog.selfIllumination() @@ -763,10 +595,10 @@ class CamConfig(private val mActivity: MainActivity) { fun setQRScanningFor(format: String, selected: Boolean) { - val formatSRep = "${SettingValues.Key.SCAN}_$format" - - commonPref.edit { - putBoolean(formatSRep, selected) + settings = runBlocking { + settingsRepository.update { + it.withBarcodeFormat(formatName = format, enabled = selected) + } } if (selected) { @@ -786,186 +618,49 @@ class CamConfig(private val mActivity: MainActivity) { qrAnalyzer?.refreshHints() } - fun reloadSettings() { - // pref config needs to be created - modePref.edit { - if (!modePref.contains(SettingValues.Key.FLASH_MODE)) { - putInt(SettingValues.Key.FLASH_MODE, SettingValues.Default.FLASH_MODE) - } - - if (!modePref.contains(SettingValues.Key.GEO_TAGGING)) { - putBoolean(SettingValues.Key.GEO_TAGGING, SettingValues.Default.GEO_TAGGING) - } + private fun slotCurrentMode() { + modeSettings = runBlocking { + settingsRepository.selectMode( + mode = currentMode, + isFrontFacing = lensFacing == CameraSelector.LENS_FACING_FRONT, + ) + } + } - if (isVideoMode) { - mActivity.settingsDialog.reloadQualities() - } + fun reloadSettings() { + slotCurrentMode() - if (lensFacing == CameraSelector.LENS_FACING_FRONT) { - if (!modePref.contains(SettingValues.Key.SELF_ILLUMINATION)) { - putBoolean( - SettingValues.Key.SELF_ILLUMINATION, - SettingValues.Default.SELF_ILLUMINATION - ) - } - } + if (isVideoMode) { + mActivity.settingsDialog.reloadQualities() } - flashMode = modePref.getInt( - SettingValues.Key.FLASH_MODE, - SettingValues.Default.FLASH_MODE - ) + applyFlashMode(modeSettings.flashMode) // A stored "on" is written before a permission request resolves, and it outlives a later // revocation, so it cannot be asserted on its own: doing so opened a permission dialog on // startup that the user never asked for. Coercing it here settles the stale value through // the setter, and leaves every dialog in the app originating from an explicit toggle. - requireLocation = modePref.getBoolean( - SettingValues.Key.GEO_TAGGING, - SettingValues.Default.GEO_TAGGING - ) && !(mActivity.applicationContext as App).shouldAskForLocationPermission() - - selfIlluminate = modePref.getBoolean( - SettingValues.Key.SELF_ILLUMINATION, - SettingValues.Default.SELF_ILLUMINATION - ) + requireLocation = modeSettings.geoTagging && + !(mActivity.applicationContext as App).shouldAskForLocationPermission() + + selfIlluminate = modeSettings.selfIllumination mActivity.settingsDialog.showOnlyRelevantSettings() } fun loadSettings() { - - // Create common config. if it's not created - val editor = commonPref.edit() - - if (!commonPref.contains(SettingValues.Key.CAMERA_SOUNDS)) { - editor.putBoolean(SettingValues.Key.CAMERA_SOUNDS, SettingValues.Default.CAMERA_SOUNDS) - } - - // Note: This is a workaround to keep save image/video as previewed 'on' by - // default starting from v73 and 'off' by default for versions before that - // - // If its not a fresh install (before v73) - if (commonPref.contains(SettingValues.Key.SAVE_IMAGE_AS_PREVIEW)) { - // If save video as previewed was not previously set - if (!commonPref.contains(SettingValues.Key.SAVE_VIDEO_AS_PREVIEW)) { - // Explicitly set the value for this setting as false for them - // to ensure consistent behavior - editor.putBoolean( - SettingValues.Key.SAVE_VIDEO_AS_PREVIEW, - false - ) - } - } else { - editor.putBoolean( - SettingValues.Key.SAVE_IMAGE_AS_PREVIEW, - SettingValues.Default.SAVE_IMAGE_AS_PREVIEW - ) - - editor.putBoolean( - SettingValues.Key.SAVE_VIDEO_AS_PREVIEW, - SettingValues.Default.SAVE_VIDEO_AS_PREVIEW - ) - } - - if (!commonPref.contains(SettingValues.Key.GRID)) { - // Index for Grid.values() Default: NONE - editor.putInt(SettingValues.Key.GRID, SettingValues.Default.GRID_TYPE_INDEX) - } - - if (!commonPref.contains(SettingValues.Key.FOCUS_TIMEOUT)) { - editor.putString(SettingValues.Key.FOCUS_TIMEOUT, SettingValues.Default.FOCUS_TIMEOUT) - } - - migrateFromLegacyPhotoQuality() - - if (!commonPref.contains(SettingValues.Key.INCLUDE_AUDIO)) { - editor.putBoolean( - SettingValues.Key.INCLUDE_AUDIO, - SettingValues.Default.INCLUDE_AUDIO - ) - } - - if (!commonPref.contains(SettingValues.Key.ENABLE_EIS)) { - editor.putBoolean( - SettingValues.Key.ENABLE_EIS, - SettingValues.Default.ENABLE_EIS - ) - } - - if (!commonPref.contains(SettingValues.Key.ASPECT_RATIO)) { - editor.putInt( - SettingValues.Key.ASPECT_RATIO, - SettingValues.Default.ASPECT_RATIO - ) - } - - if (!commonPref.contains(SettingValues.Key.SCAN_ALL_CODES)) { - editor.putBoolean( - SettingValues.Key.SCAN_ALL_CODES, - SettingValues.Default.SCAN_ALL_CODES - ) - } - - val qrRep = "${SettingValues.Key.SCAN}_${BarcodeFormat.QR_CODE.name}" - - if (!commonPref.contains(qrRep)) { - for (format in BarcodeFormat.values()) { - val formatSRep = "${SettingValues.Key.SCAN}_${format.name}" - - editor.putBoolean( - formatSRep, - false - ) - } - - editor.putBoolean( - qrRep, - true - ) - } - - - editor.apply() - - gridType = GridType.values()[commonPref.getInt( - SettingValues.Key.GRID, - SettingValues.Default.GRID_TYPE_INDEX - )] - mActivity.settingsDialog.updateGridToggleUI() - commonPref.getString(SettingValues.Key.FOCUS_TIMEOUT, SettingValues.Default.FOCUS_TIMEOUT) - ?.let { - mActivity.settingsDialog.updateFocusTimeout(it) - } + mActivity.settingsDialog.updateFocusTimeout(focusTimeoutLabel(settings.focusTimeoutSeconds)) - aspectRatio = commonPref.getInt( - SettingValues.Key.ASPECT_RATIO, - SettingValues.Default.ASPECT_RATIO - ) + includeAudio = settings.includeAudio - includeAudio = commonPref.getBoolean( - SettingValues.Key.INCLUDE_AUDIO, - SettingValues.Default.INCLUDE_AUDIO - ) - - enableEIS = commonPref.getBoolean( - SettingValues.Key.ENABLE_EIS, - SettingValues.Default.ENABLE_EIS - ) + enableEIS = settings.enableEis allowedFormats.clear() for (format in BarcodeFormat.values()) { - val formatSRep = "${SettingValues.Key.SCAN}_${format.name}" - - val isEnabled = commonPref.getBoolean( - formatSRep, - false - ) - - if (isEnabled) { + if (format.name in settings.enabledBarcodeFormats) { if (format !in allowedFormats) { allowedFormats.add(format) } @@ -993,57 +688,24 @@ class CamConfig(private val mActivity: MainActivity) { var waitForFocusLock: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.WAIT_FOR_FOCUS_LOCK, - SettingValues.Default.WAIT_FOR_FOCUS_LOCK - ) + return settings.waitForFocusLock } set(value) { - commonPref.edit { - putBoolean(SettingValues.Key.WAIT_FOR_FOCUS_LOCK, value) + settings = runBlocking { + settingsRepository.update { it.copy(waitForFocusLock = value) } } } var selectHighestResolution: Boolean get() { - return commonPref.getBoolean( - SettingValues.Key.SELECT_HIGHEST_RESOLUTION, - SettingValues.Default.SELECT_HIGHEST_RESOLUTION - ) + return settings.selectHighestResolution } set(value) { - commonPref.edit { - putBoolean(SettingValues.Key.SELECT_HIGHEST_RESOLUTION, value) + settings = runBlocking { + settingsRepository.update { it.copy(selectHighestResolution = value) } } } - fun migrateFromLegacyPhotoQuality() { - // If emphasis on quality/optimization was previously set by the user - if (commonPref.contains(SettingValues.Key.EMPHASIS_ON_QUALITY)) { - // If the photo quality key has not previously been set - if (!commonPref.contains(SettingValues.Key.PHOTO_QUALITY)) { - val optimizeForQuality = - commonPref.getBoolean(SettingValues.Key.EMPHASIS_ON_QUALITY, false) - - photoQuality = if (optimizeForQuality) { - 100 - } else { - 95 - } - } - - // Remove the key to avoid re-execution of the above code - commonPref.edit { - remove(SettingValues.Key.EMPHASIS_ON_QUALITY) - } - } - - if (photoQuality == 0) { - photoQuality = 95; - } - } - - fun toggleTorchState() { isTorchOn = !isTorchOn } @@ -1051,12 +713,14 @@ class CamConfig(private val mActivity: MainActivity) { fun toggleFlashMode() { if (isFlashAvailable) { - flashMode = when (flashMode) { + val next = when (flashMode) { ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_ON ImageCapture.FLASH_MODE_ON -> ImageCapture.FLASH_MODE_AUTO else -> ImageCapture.FLASH_MODE_OFF } + setFlashMode(next) + } else { mActivity.showMessage( getString(R.string.flash_unavailable_in_selected_mode) @@ -1349,14 +1013,16 @@ 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). - private fun describeQualityFeature(feature: GroupableFeature): String? = when (feature) { - GroupableFeatures.UHD_RECORDING -> "2160p (UHD)" - GroupableFeatures.FHD_RECORDING -> "1080p (FHD)" - GroupableFeatures.HD_RECORDING -> "720p (HD)" - GroupableFeatures.SD_RECORDING -> "480p (SD)" - else -> null + private fun describeQualityFeature(feature: GroupableFeature): String? { + val quality = when (feature) { + GroupableFeatures.UHD_RECORDING -> Quality.UHD + GroupableFeatures.FHD_RECORDING -> Quality.FHD + GroupableFeatures.HD_RECORDING -> Quality.HD + GroupableFeatures.SD_RECORDING -> Quality.SD + else -> return null + } + + return videoQualityTitle(mActivity, quality) } // Avoids repeating an unchanged notice: startCamera() runs again on every tab switch, @@ -1449,7 +1115,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. + applyFlashMode(modeSettings.flashMode) val rotation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val display = mActivity.display @@ -2027,6 +1697,20 @@ class CamConfig(private val mActivity: MainActivity) { } } + @StringRes + private fun tabLabel(mode: CameraMode): Int { + return when (mode) { + CameraMode.QR_SCAN -> R.string.qr_scan_mode + CameraMode.AUTO -> R.string.auto_mode + CameraMode.FACE_RETOUCH -> R.string.face_retouch_mode + CameraMode.PORTRAIT -> R.string.portrait_mode + CameraMode.NIGHT -> R.string.night_mode + CameraMode.HDR -> R.string.hdr_mode + CameraMode.CAMERA -> R.string.camera + CameraMode.VIDEO -> R.string.video + } + } + @SuppressLint("ClickableViewAccessibility") private fun buildTabs() { val tabLayout = mActivity.tabLayout @@ -2042,7 +1726,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) { @@ -2141,13 +1825,7 @@ class CamConfig(private val mActivity: MainActivity) { optionNames.add(format.name) - val formatSRep = "${SettingValues.Key.SCAN}_$format" - optionValues.add( - commonPref.getBoolean( - formatSRep, - false - ) - ) + optionValues.add(format.name in settings.enabledBarcodeFormats) } builder.setMultiChoiceItems( @@ -2179,24 +1857,27 @@ class CamConfig(private val mActivity: MainActivity) { } } - commonPref.edit { - for ((index, element) in optionNames.withIndex()) { - - val optionName = element - val optionValue = optionValues[index] + for ((index, optionName) in optionNames.withIndex()) { - val formatSRep = "${SettingValues.Key.SCAN}_$optionName" + val format = BarcodeFormat.valueOf(optionName) - val format = BarcodeFormat.valueOf(optionName) - - if (optionValue) { - if (format !in allowedFormats) - allowedFormats.add(format) - } else { - allowedFormats.remove(format) + if (optionValues[index]) { + if (format !in allowedFormats) { + allowedFormats.add(format) } + } else { + allowedFormats.remove(format) + } + } - putBoolean(formatSRep, optionValue) + settings = runBlocking { + settingsRepository.update { current -> + optionNames.foldIndexed(current) { index, updated, optionName -> + updated.withBarcodeFormat( + formatName = optionName, + enabled = optionValues[index], + ) + } } } @@ -2220,7 +1901,7 @@ class CamConfig(private val mActivity: MainActivity) { fun onStorageLocationNotFound() { // Reverting back to DEFAULT_MEDIA_STORE_CAPTURE_PATH - storageLocation = SettingValues.Default.STORAGE_LOCATION + storageLocation = CapturedItemRepository.MEDIA_STORE_LOCATION val builder = MaterialAlertDialogBuilder(mActivity) .setTitle(R.string.folder_not_found) diff --git a/app/src/main/java/app/grapheneos/camera/CapturedItems.kt b/app/src/main/java/app/grapheneos/camera/CapturedItems.kt index 91417ba0c..58734c8a5 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 @@ -162,60 +152,14 @@ internal fun editCapturedItem(activity: Activity, item: CapturedItem, useDefault object CapturedItems { const val TAG = "CapturedItems" + // Recent storage locations stay tracked so the gallery can still show their contents. 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) - } - } - } - } + // A tree URI containing this is rejected: the legacy list it was joined into split on it. + 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..d8f7ce5b1 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt @@ -25,6 +25,7 @@ import app.grapheneos.camera.IMAGE_NAME_PREFIX import app.grapheneos.camera.ITEM_TYPE_IMAGE import app.grapheneos.camera.capturer.ImageSaverException.Place import app.grapheneos.camera.clearExif +import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.fixExif import app.grapheneos.camera.util.ImageResizer import app.grapheneos.camera.util.executeIfAlive @@ -284,7 +285,7 @@ class ImageSaver( mainThreadExecutor.execute { imageCapturer.onThumbnailGenerated(bitmap) } } - fun saveToMediaStore() = storageLocation == CamConfig.SettingValues.Default.STORAGE_LOCATION + fun saveToMediaStore() = storageLocation == CapturedItemRepository.MEDIA_STORE_LOCATION private fun dateString() = // it's important to include milliseconds (SSS), otherwise new image may overwrite the previous one diff --git a/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt b/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt index 83819c7d9..b919ad3e9 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt @@ -32,6 +32,7 @@ import app.grapheneos.camera.CapturedItem import app.grapheneos.camera.ITEM_TYPE_VIDEO import app.grapheneos.camera.R import app.grapheneos.camera.VIDEO_NAME_PREFIX +import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.SecureMainActivity import app.grapheneos.camera.ui.activities.VideoCaptureActivity @@ -106,7 +107,7 @@ class VideoCapturer(private val mActivity: MainActivity) { } else { val storageLocation = camConfig.storageLocation - if (storageLocation == CamConfig.SettingValues.Default.STORAGE_LOCATION) { + if (storageLocation == CapturedItemRepository.MEDIA_STORE_LOCATION) { val contentValues = ContentValues().apply { put(MediaColumns.DISPLAY_NAME, fileName) put(MediaColumns.MIME_TYPE, mimeType) diff --git a/app/src/main/java/app/grapheneos/camera/data/core/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/InMemoryDataStore.kt b/app/src/main/java/app/grapheneos/camera/data/core/store/InMemoryDataStore.kt new file mode 100644 index 000000000..396835e30 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/core/store/InMemoryDataStore.kt @@ -0,0 +1,27 @@ +package app.grapheneos.camera.data.core.store + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class InMemoryDataStore( + initial: T, +) : DataStore { + + private val stored = MutableStateFlow(initial) + + override val data: Flow = stored.asStateFlow() + + private val writes = Mutex() + + override suspend fun updateData(transform: suspend (T) -> T): T { + return writes.withLock { + val updated = transform(stored.value) + stored.value = updated + updated + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/core/store/JsonPreferenceSerializer.kt b/app/src/main/java/app/grapheneos/camera/data/core/store/JsonPreferenceSerializer.kt new file mode 100644 index 000000000..af20e9054 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/core/store/JsonPreferenceSerializer.kt @@ -0,0 +1,34 @@ +package app.grapheneos.camera.data.core.store + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import java.io.InputStream +import java.io.OutputStream +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +internal class JsonPreferenceSerializer( + private val serializer: KSerializer, + override val defaultValue: T, +) : Serializer { + + override suspend fun readFrom(input: InputStream): T { + return try { + json.decodeFromString(serializer, input.readBytes().decodeToString()) + } catch (e: SerializationException) { + throw CorruptionException("unreadable preferences", e) + } + } + + override suspend fun writeTo(t: T, output: OutputStream) { + output.write(json.encodeToString(serializer, t).encodeToByteArray()) + } + + private companion object { + // Unknown keys are dropped, not kept: refusing them would wipe every setting. + val json = Json { + ignoreUnknownKeys = true + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/core/store/LegacyCommonPreferences.kt b/app/src/main/java/app/grapheneos/camera/data/core/store/LegacyCommonPreferences.kt new file mode 100644 index 000000000..f779ccfb5 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/core/store/LegacyCommonPreferences.kt @@ -0,0 +1,48 @@ +package app.grapheneos.camera.data.core.store + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit + +private const val LEGACY_COMMON_PREFS_NAME = "commons" + +internal const val LEGACY_LAST_CAPTURED_ITEM_TYPE = "last_captured_item_type" +internal const val LEGACY_LAST_CAPTURED_ITEM_DATE_STRING = "last_captured_item_date_string" +internal const val LEGACY_LAST_CAPTURED_ITEM_URI = "last_captured_item_uri" + +internal const val LEGACY_STORAGE_LOCATION = "storage_location" +internal const val LEGACY_PREVIOUS_SAF_TREES = "previous_saf_trees" +internal const val LEGACY_MEDIA_URIS = "media_uri_s" + +// Settings claims every key not explicitly owned by capture or storage. +internal val LEGACY_CAPTURE_KEYS = setOf( + LEGACY_LAST_CAPTURED_ITEM_TYPE, + LEGACY_LAST_CAPTURED_ITEM_DATE_STRING, + LEGACY_LAST_CAPTURED_ITEM_URI, +) + +internal val LEGACY_STORAGE_KEYS = setOf( + LEGACY_STORAGE_LOCATION, + LEGACY_PREVIOUS_SAF_TREES, + LEGACY_MEDIA_URIS, +) + +internal fun legacyCommonPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences(LEGACY_COMMON_PREFS_NAME, Context.MODE_PRIVATE) +} + +internal fun removeLegacyCommonKeys(context: Context, owns: (String) -> Boolean) { + val preferences = legacyCommonPreferences(context) + val owned = preferences.all.keys.filter(owns) + + // An empty edit can recreate the file after another migration deletes it. + if (owned.isNotEmpty()) { + preferences.edit(commit = true) { + owned.forEach { remove(it) } + } + } + + if (preferences.all.isEmpty()) { + context.deleteSharedPreferences(LEGACY_COMMON_PREFS_NAME) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/media/repository/CapturedItemRepository.kt b/app/src/main/java/app/grapheneos/camera/data/media/repository/CapturedItemRepository.kt new file mode 100644 index 000000000..5f1030eb9 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/repository/CapturedItemRepository.kt @@ -0,0 +1,321 @@ +package app.grapheneos.camera.data.media.repository + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.provider.BaseColumns +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.util.Log +import androidx.core.net.toUri +import androidx.datastore.core.DataStore +import app.grapheneos.camera.BuildConfig +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.CapturedItems +import app.grapheneos.camera.data.media.store.MediaPrefs +import app.grapheneos.camera.data.media.store.StoragePrefs +import app.grapheneos.camera.data.media.store.StoredCapturedItem +import dagger.hilt.android.qualifiers.ActivityContext +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +interface CapturedItemRepository { + + suspend fun lastCapturedItem(): CapturedItem? + + suspend fun saveLastCapturedItem(item: CapturedItem) + + val storageLocation: Flow + + suspend fun setStorageLocation(value: String) + + suspend fun releaseUntrackedSafTrees() + + suspend fun migrateStoredCaptures(onLastCapturedItem: (CapturedItem) -> Unit) + + suspend fun capturedItems(): List + + companion object { + const val MEDIA_STORE_LOCATION = "" + } +} + +@Suppress("TooManyFunctions") +internal class CapturedItemRepositoryImpl @Inject constructor( + private val storagePrefs: DataStore, + private val mediaPrefs: DataStore, + @ActivityContext private val context: Context, +) : CapturedItemRepository { + + override suspend fun lastCapturedItem(): CapturedItem? { + val stored = mediaPrefs.data.first().lastCapturedItem ?: return null + + return CapturedItem( + type = stored.type, + dateString = stored.dateString, + uri = stored.uri.toUri(), + ) + } + + override suspend fun saveLastCapturedItem(item: CapturedItem) { + mediaPrefs.updateData { + it.copy( + lastCapturedItem = StoredCapturedItem( + type = item.type, + dateString = item.dateString, + uri = item.uri.toString(), + ), + ) + } + } + + override val storageLocation: Flow = storagePrefs.data.map { + it.storageLocation ?: CapturedItemRepository.MEDIA_STORE_LOCATION + } + + override suspend fun setStorageLocation(value: String) { + storagePrefs.updateData { prefs -> + val previous = prefs.storageLocation?.takeIf { it.isNotEmpty() } + + when { + previous == null || previous == value -> prefs.copy(storageLocation = value) + else -> { + val trees = prefs.previousSafTrees.mapTo(ArrayList()) { it.toUri() } + val previousTree = previous.toUri() + + trees.remove(previousTree) + trees.add(0, previousTree) + + while (trees.size > CapturedItems.MAX_NUMBER_OF_TRACKED_PREVIOUS_SAF_TREES) { + // MutableList.removeLast() resolves to an API 35 Java method. + trees.removeAt(trees.lastIndex) + } + + prefs.copy( + storageLocation = value, + previousSafTrees = trees.map { it.toString() }, + ) + } + } + } + } + + @Suppress("TooGenericExceptionCaught") + override suspend fun releaseUntrackedSafTrees() { + val tracked = trackedSafTrees() + val resolver = context.contentResolver + + resolver.persistedUriPermissions.forEach { permission -> + val uri = permission.uri + val flags = CapturedItems.safTreeFlagsToRelease( + uri, + permission.isReadPermission, + permission.isWritePermission, + tracked, + ) + if (flags == 0) { + return@forEach + } + + try { + resolver.releasePersistableUriPermission(uri, flags) + } catch (e: Exception) { + if (BuildConfig.DEBUG) { + Log.d(CapturedItems.TAG, "unable to release the grant for $uri", e) + } + } + } + } + + override suspend fun migrateStoredCaptures(onLastCapturedItem: (CapturedItem) -> Unit) { + val joinedUris = storagePrefs.data.first().legacyMediaUris ?: return + val uris = joinedUris.split(LEGACY_MEDIA_URI_SEPARATOR).map { it.toUri() } + + uris.firstOrNull { it.authority != null }?.let { + reportLastCapturedItem(it, onLastCapturedItem) + } + + val trees = legacyTrees(uris) + + storagePrefs.updateData { prefs -> + when { + trees.isEmpty() -> prefs.copy(legacyMediaUris = null) + else -> prefs.copy( + previousSafTrees = trees.map { it.toString() }, + legacyMediaUris = null, + ) + } + } + } + + override suspend fun capturedItems(): List { + val resolver = context.contentResolver + val items = ArrayList() + + collectMediaStoreItems(resolver, MediaStore.VOLUME_EXTERNAL_PRIMARY, items) + + trackedSafTrees().forEach { + if (Thread.interrupted()) { + throw InterruptedException() + } + collectSafItems(resolver, it, items) + } + + return items.distinct() + } + + internal suspend fun trackedSafTrees(): List { + val prefs = storagePrefs.data.first() + val trees = ArrayList() + + prefs.storageLocation + ?.takeIf { it.isNotEmpty() } + ?.let { trees.add(it.toUri()) } + + prefs.previousSafTrees.mapTo(trees) { it.toUri() } + + return trees.distinct() + } + + @Suppress("TooGenericExceptionCaught") + private fun collectMediaStoreItems( + resolver: ContentResolver, + volumeName: String, + dest: ArrayList, + ) { + val volumeUri = MediaStore.Files.getContentUri(volumeName) + val columns = arrayOf(BaseColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME) + val idColumn = 0 + val nameColumn = 1 + + try { + resolver.query(volumeUri, columns, null, null)?.use { + dest.ensureCapacity(it.count) + + while (it.moveToNext()) { + val name = it.getString(nameColumn) + val uri = ContentUris.withAppendedId(volumeUri, it.getLong(idColumn)) + + CapturedItems.parseCapturedItem(name, uri)?.let { item -> + dest.add(item) + } + } + } + } catch (e: Exception) { + Log.d(CapturedItems.TAG, "unable to collect MediaStore items, volume $volumeName", e) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun collectSafItems( + resolver: ContentResolver, + treeUri: Uri, + dest: ArrayList, + ) { + val treeId = DocumentsContract.getTreeDocumentId(treeUri) + val childDocumentsUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, treeId) + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + ) + val idColumn = 0 + val nameColumn = 1 + + try { + resolver.query(childDocumentsUri, columns, null, null)?.use { + dest.ensureCapacity(it.count) + + while (it.moveToNext()) { + val name = it.getString(nameColumn) + val id = it.getString(idColumn) + val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id) + + CapturedItems.parseCapturedItem(name, uri)?.let { item -> + dest.add(item) + } + } + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) { + Log.d(CapturedItems.TAG, "unable to collect SAF items, treeUri $treeUri", e) + } + } + } + + /** Unbounded, unlike the tracked list: a tree left out here loses its grant for good. */ + private suspend fun legacyTrees(uris: List): List { + val currentTreeUri = storageLocation + .first() + .takeIf { it != CapturedItemRepository.MEDIA_STORE_LOCATION } + ?.toUri() + + val trees = ArrayList() + + uris.forEach { uri -> + val authority = uri.authority ?: return@forEach + + 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 + } + + @Suppress("TooGenericExceptionCaught") + private fun reportLastCapturedItem(uri: Uri, onLastCapturedItem: (CapturedItem) -> Unit) { + val columnName = when (uri.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 (e: Exception) { + if (BuildConfig.DEBUG) { + Log.d(CapturedItems.TAG, "unable to read the name of $uri", e) + } + } + + fileName?.let { name -> + CapturedItems.parseCapturedItem(name, uri)?.let(onLastCapturedItem) + } + } + + companion object { + private const val LEGACY_MEDIA_URI_SEPARATOR = ";" + } +} + +internal class LockscreenCapturedItemRepository( + private val delegate: CapturedItemRepository, +) : CapturedItemRepository by delegate { + + @Suppress("EmptyFunctionBlock") + override suspend fun releaseUntrackedSafTrees() { + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefs.kt b/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefs.kt new file mode 100644 index 000000000..096eb5306 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefs.kt @@ -0,0 +1,26 @@ +package app.grapheneos.camera.data.media.store + +import androidx.datastore.core.Serializer +import app.grapheneos.camera.data.core.store.JsonPreferenceSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class MediaPrefs( + @SerialName("last_captured_item") val lastCapturedItem: StoredCapturedItem? = null, +) + +@Serializable +internal data class StoredCapturedItem( + @SerialName("type") + val type: Int, + @SerialName("date_string") + val dateString: String, + @SerialName("uri") + val uri: String, +) + +internal val mediaPrefsSerializer: Serializer = JsonPreferenceSerializer( + serializer = MediaPrefs.serializer(), + defaultValue = MediaPrefs(), +) diff --git a/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefsMigration.kt b/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefsMigration.kt new file mode 100644 index 000000000..5b0cae222 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefsMigration.kt @@ -0,0 +1,63 @@ +package app.grapheneos.camera.data.media.store + +import android.content.Context +import android.content.SharedPreferences +import androidx.datastore.core.DataMigration +import app.grapheneos.camera.data.core.store.LEGACY_CAPTURE_KEYS +import app.grapheneos.camera.data.core.store.LEGACY_LAST_CAPTURED_ITEM_DATE_STRING +import app.grapheneos.camera.data.core.store.LEGACY_LAST_CAPTURED_ITEM_TYPE +import app.grapheneos.camera.data.core.store.LEGACY_LAST_CAPTURED_ITEM_URI +import app.grapheneos.camera.data.core.store.legacyCommonPreferences +import app.grapheneos.camera.data.core.store.removeLegacyCommonKeys + +// The stores migrate the shared file in arbitrary order, so each removes only its own keys. +internal class MediaPrefsMigration( + private val context: Context, +) : DataMigration { + + override suspend fun shouldMigrate(currentData: MediaPrefs): Boolean { + return legacyMediaPreferences().all.keys.any(::owns) || + legacyCommonPreferences(context).all.keys.any(::owns) + } + + override suspend fun migrate(currentData: MediaPrefs): MediaPrefs { + val stored = currentData.lastCapturedItem + ?: readLastCapturedItem(legacyMediaPreferences()) + ?: readLastCapturedItem(legacyCommonPreferences(context)) + + return currentData.copy(lastCapturedItem = stored) + } + + override suspend fun cleanUp() { + context.deleteSharedPreferences(LEGACY_MEDIA_PREFS_NAME) + removeLegacyCommonKeys(context, ::owns) + } + + private fun owns(key: String): Boolean { + return key in LEGACY_CAPTURE_KEYS + } + + private fun readLastCapturedItem(preferences: SharedPreferences): StoredCapturedItem? { + val dateString = preferences.getString(LEGACY_LAST_CAPTURED_ITEM_DATE_STRING, null) + val uri = preferences.getString(LEGACY_LAST_CAPTURED_ITEM_URI, null) + + return when { + dateString == null || uri == null -> null + else -> { + StoredCapturedItem( + type = preferences.getInt(LEGACY_LAST_CAPTURED_ITEM_TYPE, -1), + dateString = dateString, + uri = uri, + ) + } + } + } + + private fun legacyMediaPreferences(): SharedPreferences { + return context.getSharedPreferences(LEGACY_MEDIA_PREFS_NAME, Context.MODE_PRIVATE) + } + + private companion object { + const val LEGACY_MEDIA_PREFS_NAME = "media" + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefs.kt b/app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefs.kt new file mode 100644 index 000000000..3cb9c19d8 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefs.kt @@ -0,0 +1,22 @@ +package app.grapheneos.camera.data.media.store + +import androidx.datastore.core.Serializer +import app.grapheneos.camera.data.core.store.JsonPreferenceSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// The current tree and displaced grants share a file so changing location updates both atomically. +@Serializable +internal data class StoragePrefs( + @SerialName("storage_location") + val storageLocation: String? = null, + @SerialName("previous_saf_trees") + val previousSafTrees: List = emptyList(), + @SerialName("legacy_media_uris") + val legacyMediaUris: String? = null, +) + +internal val storagePrefsSerializer: Serializer = JsonPreferenceSerializer( + serializer = StoragePrefs.serializer(), + defaultValue = StoragePrefs(), +) diff --git a/app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefsMigration.kt b/app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefsMigration.kt new file mode 100644 index 000000000..c9b1d83c3 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/store/StoragePrefsMigration.kt @@ -0,0 +1,60 @@ +package app.grapheneos.camera.data.media.store + +import android.content.Context +import android.content.SharedPreferences +import androidx.datastore.core.DataMigration +import app.grapheneos.camera.data.core.store.LEGACY_MEDIA_URIS +import app.grapheneos.camera.data.core.store.LEGACY_PREVIOUS_SAF_TREES +import app.grapheneos.camera.data.core.store.LEGACY_STORAGE_KEYS +import app.grapheneos.camera.data.core.store.LEGACY_STORAGE_LOCATION +import app.grapheneos.camera.data.core.store.legacyCommonPreferences +import app.grapheneos.camera.data.core.store.removeLegacyCommonKeys + +// The stores migrate the shared file in arbitrary order, so each removes only its own keys. +internal class StoragePrefsMigration( + private val context: Context, +) : DataMigration { + + override suspend fun shouldMigrate(currentData: StoragePrefs): Boolean { + return legacyCommonPreferences(context).all.keys.any(::owns) + } + + override suspend fun migrate(currentData: StoragePrefs): StoragePrefs { + val commons = legacyCommonPreferences(context) + + return currentData.copy( + storageLocation = stringOrNull(commons, LEGACY_STORAGE_LOCATION) + ?: currentData.storageLocation, + previousSafTrees = readPreviousSafTrees(commons) + ?: currentData.previousSafTrees, + legacyMediaUris = stringOrNull(commons, LEGACY_MEDIA_URIS) + ?: currentData.legacyMediaUris, + ) + } + + override suspend fun cleanUp() { + removeLegacyCommonKeys(context, ::owns) + } + + private fun owns(key: String): Boolean { + return key in LEGACY_STORAGE_KEYS + } + + private fun readPreviousSafTrees(commons: SharedPreferences): List? { + val stored = stringOrNull(commons, LEGACY_PREVIOUS_SAF_TREES) ?: return null + + return stored.split(SAF_TREE_SEPARATOR).filter { it.isNotEmpty() } + } + + private fun stringOrNull(preferences: SharedPreferences, key: String): String? { + return when { + preferences.contains(key) -> preferences.getString(key, null) + else -> null + } + } + + private companion object { + // This separator belongs to the shipped legacy format. + const val SAF_TREE_SEPARATOR = "\u0000" + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/mapper/CameraSettingsMapper.kt b/app/src/main/java/app/grapheneos/camera/data/settings/mapper/CameraSettingsMapper.kt new file mode 100644 index 000000000..f44e15769 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/mapper/CameraSettingsMapper.kt @@ -0,0 +1,108 @@ +package app.grapheneos.camera.data.settings.mapper + +import app.grapheneos.camera.data.settings.model.CameraSettings +import app.grapheneos.camera.data.settings.model.GridType +import app.grapheneos.camera.data.settings.model.SettingsDefaults +import app.grapheneos.camera.data.settings.store.StoredCameraSettings +import app.grapheneos.camera.data.settings.store.StoredGridType +import javax.inject.Inject + +internal interface CameraSettingsMapper { + + fun map(stored: StoredCameraSettings): CameraSettings + + fun map(settings: CameraSettings): StoredCameraSettings +} + +internal class CameraSettingsMapperImpl @Inject constructor() : CameraSettingsMapper { + + @Suppress("CyclomaticComplexMethod") + override fun map(stored: StoredCameraSettings): CameraSettings { + return CameraSettings( + aspectRatio = stored.aspectRatio ?: SettingsDefaults.ASPECT_RATIO, + gridType = map(stored.gridType), + focusTimeoutSeconds = stored.focusTimeoutSeconds + ?: SettingsDefaults.FOCUS_TIMEOUT_SECONDS, + selfTimerDurationSeconds = stored.selfTimerDurationSeconds + ?: SettingsDefaults.SELF_TIMER_DURATION, + enableCameraSounds = stored.enableCameraSounds ?: SettingsDefaults.CAMERA_SOUNDS, + includeAudio = stored.includeAudio ?: SettingsDefaults.INCLUDE_AUDIO, + enableEis = stored.enableEis ?: SettingsDefaults.ENABLE_EIS, + enableZsl = stored.enableZsl ?: SettingsDefaults.ENABLE_ZSL, + waitForFocusLock = stored.waitForFocusLock ?: SettingsDefaults.WAIT_FOR_FOCUS_LOCK, + selectHighestResolution = stored.selectHighestResolution + ?: SettingsDefaults.SELECT_HIGHEST_RESOLUTION, + photoQuality = stored.photoQuality ?: SettingsDefaults.PHOTO_QUALITY, + removeExifAfterCapture = stored.removeExifAfterCapture + ?: SettingsDefaults.REMOVE_EXIF_AFTER_CAPTURE, + gyroscopeSuggestions = stored.gyroscopeSuggestions + ?: SettingsDefaults.GYROSCOPE_SUGGESTIONS, + saveImageAsPreviewed = stored.saveImageAsPreviewed + ?: SettingsDefaults.SAVE_IMAGE_AS_PREVIEW, + saveVideoAsPreviewed = stored.saveVideoAsPreviewed + ?: SettingsDefaults.SAVE_VIDEO_AS_PREVIEW, + scanAllCodes = stored.scanAllCodes ?: SettingsDefaults.SCAN_ALL_CODES, + enabledBarcodeFormats = stored.enabledBarcodeFormats + ?: SettingsDefaults.ENABLED_BARCODE_FORMATS, + ) + } + + @Suppress("SimplifyBooleanWithConstants") + override fun map(settings: CameraSettings): StoredCameraSettings { + return StoredCameraSettings( + aspectRatio = settings.aspectRatio.takeUnless { it == SettingsDefaults.ASPECT_RATIO }, + gridType = settings.gridType + .takeUnless { it == SettingsDefaults.GRID_TYPE } + ?.let(::map), + focusTimeoutSeconds = settings.focusTimeoutSeconds + .takeUnless { it == SettingsDefaults.FOCUS_TIMEOUT_SECONDS }, + selfTimerDurationSeconds = settings.selfTimerDurationSeconds + .takeUnless { it == SettingsDefaults.SELF_TIMER_DURATION }, + enableCameraSounds = settings.enableCameraSounds + .takeUnless { it == SettingsDefaults.CAMERA_SOUNDS }, + includeAudio = settings.includeAudio + .takeUnless { it == SettingsDefaults.INCLUDE_AUDIO }, + enableEis = settings.enableEis.takeUnless { it == SettingsDefaults.ENABLE_EIS }, + enableZsl = settings.enableZsl.takeUnless { it == SettingsDefaults.ENABLE_ZSL }, + waitForFocusLock = settings.waitForFocusLock + .takeUnless { it == SettingsDefaults.WAIT_FOR_FOCUS_LOCK }, + selectHighestResolution = settings.selectHighestResolution + .takeUnless { it == SettingsDefaults.SELECT_HIGHEST_RESOLUTION }, + photoQuality = settings.photoQuality + .takeUnless { it == SettingsDefaults.PHOTO_QUALITY }, + removeExifAfterCapture = settings.removeExifAfterCapture + .takeUnless { it == SettingsDefaults.REMOVE_EXIF_AFTER_CAPTURE }, + gyroscopeSuggestions = settings.gyroscopeSuggestions + .takeUnless { it == SettingsDefaults.GYROSCOPE_SUGGESTIONS }, + saveImageAsPreviewed = settings.saveImageAsPreviewed + .takeUnless { it == SettingsDefaults.SAVE_IMAGE_AS_PREVIEW }, + saveVideoAsPreviewed = settings.saveVideoAsPreviewed + .takeUnless { it == SettingsDefaults.SAVE_VIDEO_AS_PREVIEW }, + scanAllCodes = settings.scanAllCodes + .takeUnless { it == SettingsDefaults.SCAN_ALL_CODES }, + enabledBarcodeFormats = settings.enabledBarcodeFormats + .takeUnless { it == SettingsDefaults.ENABLED_BARCODE_FORMATS }, + ) + } + + private fun map(stored: StoredGridType?): GridType { + return when (stored) { + null, + StoredGridType.UNKNOWN, + -> SettingsDefaults.GRID_TYPE + StoredGridType.NONE -> GridType.NONE + StoredGridType.THREE_BY_THREE -> GridType.THREE_BY_THREE + StoredGridType.FOUR_BY_FOUR -> GridType.FOUR_BY_FOUR + StoredGridType.GOLDEN_RATIO -> GridType.GOLDEN_RATIO + } + } + + private fun map(type: GridType): StoredGridType { + return when (type) { + GridType.NONE -> StoredGridType.NONE + GridType.THREE_BY_THREE -> StoredGridType.THREE_BY_THREE + GridType.FOUR_BY_FOUR -> StoredGridType.FOUR_BY_FOUR + GridType.GOLDEN_RATIO -> StoredGridType.GOLDEN_RATIO + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/mapper/ModeSettingsMapper.kt b/app/src/main/java/app/grapheneos/camera/data/settings/mapper/ModeSettingsMapper.kt new file mode 100644 index 000000000..1cdf96046 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/mapper/ModeSettingsMapper.kt @@ -0,0 +1,40 @@ +package app.grapheneos.camera.data.settings.mapper + +import androidx.camera.video.Quality +import app.grapheneos.camera.data.settings.model.ModeSettings +import app.grapheneos.camera.data.settings.model.SettingsDefaults +import app.grapheneos.camera.data.settings.store.StoredModeSettings +import app.grapheneos.camera.data.settings.store.StoredVideoQuality +import javax.inject.Inject + +internal interface ModeSettingsMapper { + + fun map(stored: StoredModeSettings, isFrontFacing: Boolean): ModeSettings +} + +internal class ModeSettingsMapperImpl @Inject constructor() : ModeSettingsMapper { + + override fun map(stored: StoredModeSettings, isFrontFacing: Boolean): ModeSettings { + val storedQuality = when { + isFrontFacing -> stored.videoQualityFront + else -> stored.videoQualityBack + } + + return ModeSettings( + flashMode = stored.flashMode ?: SettingsDefaults.FLASH_MODE, + geoTagging = stored.geoTagging ?: SettingsDefaults.GEO_TAGGING, + selfIllumination = stored.selfIllumination ?: SettingsDefaults.SELF_ILLUMINATION, + videoQuality = mapVideoQuality(storedQuality), + ) + } + + private fun mapVideoQuality(stored: StoredVideoQuality): Quality { + return when (stored) { + StoredVideoQuality.DEVICE_CHOICE -> SettingsDefaults.VIDEO_QUALITY + StoredVideoQuality.UHD -> Quality.UHD + StoredVideoQuality.FHD -> Quality.FHD + StoredVideoQuality.HD -> Quality.HD + StoredVideoQuality.SD -> Quality.SD + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/mapper/StoredVideoQualityMapper.kt b/app/src/main/java/app/grapheneos/camera/data/settings/mapper/StoredVideoQualityMapper.kt new file mode 100644 index 000000000..af1944314 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/mapper/StoredVideoQualityMapper.kt @@ -0,0 +1,23 @@ +package app.grapheneos.camera.data.settings.mapper + +import androidx.camera.video.Quality +import app.grapheneos.camera.data.settings.store.StoredVideoQuality +import javax.inject.Inject + +internal interface StoredVideoQualityMapper { + + fun map(quality: Quality): StoredVideoQuality +} + +internal class StoredVideoQualityMapperImpl @Inject constructor() : StoredVideoQualityMapper { + + override fun map(quality: Quality): StoredVideoQuality { + return when (quality) { + Quality.UHD -> StoredVideoQuality.UHD + Quality.FHD -> StoredVideoQuality.FHD + Quality.HD -> StoredVideoQuality.HD + Quality.SD -> StoredVideoQuality.SD + else -> StoredVideoQuality.DEVICE_CHOICE + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/model/CameraSettings.kt b/app/src/main/java/app/grapheneos/camera/data/settings/model/CameraSettings.kt new file mode 100644 index 000000000..d4681d171 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/CameraSettings.kt @@ -0,0 +1,41 @@ +package app.grapheneos.camera.data.settings.model + +data class CameraSettings( + val aspectRatio: Int = SettingsDefaults.ASPECT_RATIO, + val gridType: GridType = SettingsDefaults.GRID_TYPE, + val focusTimeoutSeconds: Long = SettingsDefaults.FOCUS_TIMEOUT_SECONDS, + val selfTimerDurationSeconds: Int = SettingsDefaults.SELF_TIMER_DURATION, + val enableCameraSounds: Boolean = SettingsDefaults.CAMERA_SOUNDS, + val includeAudio: Boolean = SettingsDefaults.INCLUDE_AUDIO, + val enableEis: Boolean = SettingsDefaults.ENABLE_EIS, + val enableZsl: Boolean = SettingsDefaults.ENABLE_ZSL, + val waitForFocusLock: Boolean = SettingsDefaults.WAIT_FOR_FOCUS_LOCK, + val selectHighestResolution: Boolean = SettingsDefaults.SELECT_HIGHEST_RESOLUTION, + val photoQuality: Int = SettingsDefaults.PHOTO_QUALITY, + val removeExifAfterCapture: Boolean = SettingsDefaults.REMOVE_EXIF_AFTER_CAPTURE, + val gyroscopeSuggestions: Boolean = SettingsDefaults.GYROSCOPE_SUGGESTIONS, + val saveImageAsPreviewed: Boolean = SettingsDefaults.SAVE_IMAGE_AS_PREVIEW, + val saveVideoAsPreviewed: Boolean = SettingsDefaults.SAVE_VIDEO_AS_PREVIEW, + val scanAllCodes: Boolean = SettingsDefaults.SCAN_ALL_CODES, + val enabledBarcodeFormats: Set = SettingsDefaults.ENABLED_BARCODE_FORMATS, +) { + fun withBarcodeFormat(formatName: String, enabled: Boolean): CameraSettings { + val formats = when { + enabled -> enabledBarcodeFormats + formatName + else -> enabledBarcodeFormats - formatName + } + + return copy(enabledBarcodeFormats = formats) + } + + companion object { + const val FOCUS_TIMEOUT_OFF = "Off" + } +} + +fun focusTimeoutLabel(seconds: Long): String { + return when (seconds) { + 0L -> CameraSettings.FOCUS_TIMEOUT_OFF + else -> "${seconds}s" + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/model/GridType.kt b/app/src/main/java/app/grapheneos/camera/data/settings/model/GridType.kt new file mode 100644 index 000000000..c7f60e79a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/GridType.kt @@ -0,0 +1,8 @@ +package app.grapheneos.camera.data.settings.model + +enum class GridType { + NONE, + THREE_BY_THREE, + FOUR_BY_FOUR, + GOLDEN_RATIO, +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/model/ModeSettings.kt b/app/src/main/java/app/grapheneos/camera/data/settings/model/ModeSettings.kt new file mode 100644 index 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..3d7a33348 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/SettingsDefaults.kt @@ -0,0 +1,51 @@ +package app.grapheneos.camera.data.settings.model + +import androidx.camera.core.AspectRatio +import androidx.camera.core.ImageCapture +import androidx.camera.video.Quality +import com.google.zxing.BarcodeFormat + +object SettingsDefaults { + + val GRID_TYPE = GridType.NONE + + val VIDEO_QUALITY: Quality = Quality.HIGHEST + + val ENABLED_BARCODE_FORMATS = setOf(BarcodeFormat.QR_CODE.name) + + const val ASPECT_RATIO = AspectRatio.RATIO_4_3 + + const val FLASH_MODE = ImageCapture.FLASH_MODE_OFF + + const val FOCUS_TIMEOUT_SECONDS = 5L + + const val SELF_ILLUMINATION = false + + const val GEO_TAGGING = false + + const val INCLUDE_AUDIO = true + + const val ENABLE_EIS = true + + const val SCAN_ALL_CODES = false + + const val SAVE_IMAGE_AS_PREVIEW = true + + const val SAVE_VIDEO_AS_PREVIEW = true + + const val PHOTO_QUALITY = 95 + + const val REMOVE_EXIF_AFTER_CAPTURE = true + + const val GYROSCOPE_SUGGESTIONS = false + + const val CAMERA_SOUNDS = true + + const val ENABLE_ZSL = false + + const val SELECT_HIGHEST_RESOLUTION = false + + const val WAIT_FOR_FOCUS_LOCK = false + + const val SELF_TIMER_DURATION = 0 +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsRepository.kt b/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsRepository.kt new file mode 100644 index 000000000..6e7535da8 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsRepository.kt @@ -0,0 +1,126 @@ +package app.grapheneos.camera.data.settings.repository + +import androidx.camera.video.Quality +import androidx.datastore.core.DataStore +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapper +import app.grapheneos.camera.data.settings.mapper.ModeSettingsMapper +import app.grapheneos.camera.data.settings.mapper.StoredVideoQualityMapper +import app.grapheneos.camera.data.settings.model.CameraSettings +import app.grapheneos.camera.data.settings.model.ModeSettings +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.data.settings.store.StoredModeSettings +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +interface SettingsRepository { + + val settings: Flow + + suspend fun update(transform: (CameraSettings) -> CameraSettings): CameraSettings + + suspend fun selectMode(mode: CameraMode, isFrontFacing: Boolean): ModeSettings + + suspend fun setFlashMode(value: Int): ModeSettings? + + suspend fun setGeoTagging(value: Boolean): ModeSettings? + + suspend fun setSelfIllumination(value: Boolean): ModeSettings? + + suspend fun setVideoQuality(value: Quality): ModeSettings? +} + +internal class SettingsRepositoryImpl @Inject constructor( + private val dataStore: DataStore, + private val cameraSettingsMapper: CameraSettingsMapper, + private val modeSettingsMapper: ModeSettingsMapper, + private val storedVideoQualityMapper: StoredVideoQualityMapper, +) : SettingsRepository { + + override val settings: Flow = dataStore.data.map { + cameraSettingsMapper.map(it.common) + } + + private var slotted: SlottedMode? = null + + override suspend fun update(transform: (CameraSettings) -> CameraSettings): CameraSettings { + return dataStore + .updateData { prefs -> + val settings = transform(cameraSettingsMapper.map(prefs.common)) + + prefs.copy(common = cameraSettingsMapper.map(settings)) + } + .let { cameraSettingsMapper.map(it.common) } + } + + override suspend fun selectMode(mode: CameraMode, isFrontFacing: Boolean): ModeSettings { + slotted = SlottedMode(mode = mode, isFrontFacing = isFrontFacing) + + return modeSettingsMapper.map( + stored = dataStore.data.first().mode(mode), + isFrontFacing = isFrontFacing, + ) + } + + override suspend fun setFlashMode(value: Int): ModeSettings? { + return writeMode( + store = { stored, _ -> stored.copy(flashMode = value) }, + asRequested = { it.copy(flashMode = value) }, + ) + } + + override suspend fun setGeoTagging(value: Boolean): ModeSettings? { + return writeMode( + store = { stored, _ -> stored.copy(geoTagging = value) }, + asRequested = { it.copy(geoTagging = value) }, + ) + } + + override suspend fun setSelfIllumination(value: Boolean): ModeSettings? { + return writeMode( + store = { stored, _ -> stored.copy(selfIllumination = value) }, + asRequested = { it.copy(selfIllumination = value) }, + ) + } + + override suspend fun setVideoQuality(value: Quality): ModeSettings? { + return writeMode( + store = { stored, slot -> + val quality = storedVideoQualityMapper.map(value) + + when { + slot.isFrontFacing -> stored.copy(videoQualityFront = quality) + else -> stored.copy(videoQualityBack = quality) + } + }, + asRequested = { it.copy(videoQuality = value) }, + ) + } + + private suspend fun writeMode( + store: (StoredModeSettings, SlottedMode) -> StoredModeSettings, + asRequested: (ModeSettings) -> ModeSettings, + ): ModeSettings? { + val slot = slotted ?: return null + + val prefs = dataStore.updateData { prefs -> + val stored = store(prefs.mode(slot.mode), slot) + + prefs.withMode(mode = slot.mode, settings = stored) + } + + return asRequested( + modeSettingsMapper.map( + stored = prefs.mode(slot.mode), + isFrontFacing = slot.isFrontFacing, + ), + ) + } + + private data class SlottedMode( + val mode: CameraMode, + val isFrontFacing: Boolean, + ) +} diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefs.kt b/app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefs.kt new file mode 100644 index 000000000..bd528367c --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefs.kt @@ -0,0 +1,177 @@ +package app.grapheneos.camera.data.settings.store + +import androidx.datastore.core.Serializer +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.core.store.JsonPreferenceSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable +internal data class SettingsPrefs( + val common: StoredCameraSettings = StoredCameraSettings(), + val modes: Map = emptyMap(), +) { + fun mode(mode: CameraMode): StoredModeSettings { + return modes[storedModeName(mode)] ?: StoredModeSettings() + } + + fun withMode(mode: CameraMode, settings: StoredModeSettings): SettingsPrefs { + return copy(modes = modes + (storedModeName(mode) to settings)) + } + + companion object { + fun storedModeName(mode: CameraMode): String { + return when (mode) { + CameraMode.QR_SCAN -> "QR_SCAN" + CameraMode.AUTO -> "AUTO" + CameraMode.FACE_RETOUCH -> "FACE_RETOUCH" + CameraMode.PORTRAIT -> "PORTRAIT" + CameraMode.NIGHT -> "NIGHT" + CameraMode.HDR -> "HDR" + CameraMode.CAMERA -> "CAMERA" + CameraMode.VIDEO -> "VIDEO" + } + } + } +} + +@Serializable +internal data class StoredCameraSettings( + @SerialName("aspect_ratio") + val aspectRatio: Int? = null, + @SerialName("grid_type") + val gridType: StoredGridType? = null, + @SerialName("focus_timeout_seconds") + val focusTimeoutSeconds: Long? = null, + @SerialName("self_timer_duration_seconds") + val selfTimerDurationSeconds: Int? = null, + @SerialName("enable_camera_sounds") + val enableCameraSounds: Boolean? = null, + @SerialName("include_audio") + val includeAudio: Boolean? = null, + @SerialName("enable_eis") + val enableEis: Boolean? = null, + @SerialName("enable_zsl") + val enableZsl: Boolean? = null, + @SerialName("wait_for_focus_lock") + val waitForFocusLock: Boolean? = null, + @SerialName("select_highest_resolution") + val selectHighestResolution: Boolean? = null, + @SerialName("photo_quality") + val photoQuality: Int? = null, + @SerialName("remove_exif_after_capture") + val removeExifAfterCapture: Boolean? = null, + @SerialName("gyroscope_suggestions") + val gyroscopeSuggestions: Boolean? = null, + @SerialName("save_image_as_previewed") + val saveImageAsPreviewed: Boolean? = null, + @SerialName("save_video_as_previewed") + val saveVideoAsPreviewed: Boolean? = null, + @SerialName("scan_all_codes") + val scanAllCodes: Boolean? = null, + @SerialName("enabled_barcode_formats") + val enabledBarcodeFormats: Set? = null, +) + +@Serializable +internal data class StoredModeSettings( + @SerialName("flash_mode") + val flashMode: Int? = null, + @SerialName("geo_tagging") + val geoTagging: Boolean? = null, + @SerialName("self_illumination") + val selfIllumination: Boolean? = null, + @SerialName("video_quality_front") + val videoQualityFront: StoredVideoQuality = StoredVideoQuality.DEVICE_CHOICE, + @SerialName("video_quality_back") + val videoQualityBack: StoredVideoQuality = StoredVideoQuality.DEVICE_CHOICE, +) + +@Serializable(with = StoredGridTypeSerializer::class) +internal enum class StoredGridType { + UNKNOWN, + NONE, + THREE_BY_THREE, + FOUR_BY_FOUR, + GOLDEN_RATIO, +} + +internal object StoredGridTypeSerializer : KSerializer { + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + serialName = "StoredGridType", + kind = PrimitiveKind.STRING, + ) + + override fun serialize(encoder: Encoder, value: StoredGridType) { + encoder.encodeString(wireName(value)) + } + + override fun deserialize(decoder: Decoder): StoredGridType { + val stored = decoder.decodeString() + + return StoredGridType.entries.firstOrNull { wireName(it) == stored } + ?: StoredGridType.UNKNOWN + } + + private fun wireName(type: StoredGridType): String { + return when (type) { + StoredGridType.UNKNOWN -> "UNKNOWN" + StoredGridType.NONE -> "NONE" + StoredGridType.THREE_BY_THREE -> "THREE_BY_THREE" + StoredGridType.FOUR_BY_FOUR -> "FOUR_BY_FOUR" + StoredGridType.GOLDEN_RATIO -> "GOLDEN_RATIO" + } + } +} + +@Serializable(with = StoredVideoQualitySerializer::class) +internal enum class StoredVideoQuality { + DEVICE_CHOICE, + UHD, + FHD, + HD, + SD, +} + +// Unknown enum names must not make DataStore replace the entire settings file as corrupt. +internal object StoredVideoQualitySerializer : KSerializer { + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + serialName = "StoredVideoQuality", + kind = PrimitiveKind.STRING, + ) + + override fun serialize(encoder: Encoder, value: StoredVideoQuality) { + encoder.encodeString(wireName(value)) + } + + override fun deserialize(decoder: Decoder): StoredVideoQuality { + val stored = decoder.decodeString() + + return StoredVideoQuality.entries.firstOrNull { wireName(it) == stored } + ?: StoredVideoQuality.DEVICE_CHOICE + } + + // Enum constant names are not the persisted wire format. + private fun wireName(quality: StoredVideoQuality): String { + return when (quality) { + StoredVideoQuality.DEVICE_CHOICE -> "DEVICE_CHOICE" + StoredVideoQuality.UHD -> "UHD" + StoredVideoQuality.FHD -> "FHD" + StoredVideoQuality.HD -> "HD" + StoredVideoQuality.SD -> "SD" + } + } +} + +internal val settingsPrefsSerializer: Serializer = JsonPreferenceSerializer( + serializer = SettingsPrefs.serializer(), + defaultValue = SettingsPrefs(), +) diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefsMigration.kt b/app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefsMigration.kt new file mode 100644 index 000000000..b67b3c064 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/store/SettingsPrefsMigration.kt @@ -0,0 +1,258 @@ +package app.grapheneos.camera.data.settings.store + +import android.content.Context +import android.content.SharedPreferences +import androidx.datastore.core.DataMigration +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.core.store.LEGACY_CAPTURE_KEYS +import app.grapheneos.camera.data.core.store.LEGACY_STORAGE_KEYS +import app.grapheneos.camera.data.core.store.legacyCommonPreferences +import app.grapheneos.camera.data.core.store.removeLegacyCommonKeys +import app.grapheneos.camera.data.settings.model.SettingsDefaults + +// Settings owns every shared-file key not claimed by capture or storage. The constants and +// fallbacks below describe shipped formats and must remain independent of current UI names. +@Suppress("TooManyFunctions") +internal class SettingsPrefsMigration( + private val context: Context, +) : DataMigration { + + override suspend fun shouldMigrate(currentData: SettingsPrefs): Boolean { + val ownsSomething = legacyCommonPreferences(context).all.keys.any(::owns) + + return ownsSomething || CameraMode.entries.any { + preferences(SettingsPrefs.storedModeName(it)).all.isNotEmpty() + } + } + + override suspend fun migrate(currentData: SettingsPrefs): SettingsPrefs { + return currentData.copy( + common = readCommonSettings( + commons = legacyCommonPreferences(context), + current = currentData.common, + ), + modes = readModeSettings(currentData), + ) + } + + override suspend fun cleanUp() { + CameraMode.entries.forEach { mode -> + context.deleteSharedPreferences(SettingsPrefs.storedModeName(mode)) + } + + removeLegacyCommonKeys(context, ::owns) + } + + private fun owns(key: String): Boolean { + return key !in LEGACY_CAPTURE_KEYS && key !in LEGACY_STORAGE_KEYS + } + + @Suppress("CyclomaticComplexMethod") + private fun readCommonSettings( + commons: SharedPreferences, + current: StoredCameraSettings, + ): StoredCameraSettings { + return current.copy( + aspectRatio = intOrNull(commons, ASPECT_RATIO) ?: current.aspectRatio, + gridType = readGridType(commons) ?: current.gridType, + focusTimeoutSeconds = readFocusTimeoutSeconds(commons) + ?: current.focusTimeoutSeconds, + selfTimerDurationSeconds = intOrNull(commons, SELF_TIMER_DURATION) + ?: current.selfTimerDurationSeconds, + enableCameraSounds = booleanOrNull(commons, CAMERA_SOUNDS) + ?: current.enableCameraSounds, + includeAudio = booleanOrNull(commons, INCLUDE_AUDIO) ?: current.includeAudio, + enableEis = booleanOrNull(commons, ENABLE_EIS) ?: current.enableEis, + enableZsl = booleanOrNull(commons, ENABLE_ZSL) ?: current.enableZsl, + waitForFocusLock = booleanOrNull(commons, WAIT_FOR_FOCUS_LOCK) + ?: current.waitForFocusLock, + selectHighestResolution = booleanOrNull(commons, SELECT_HIGHEST_RESOLUTION) + ?: current.selectHighestResolution, + photoQuality = readPhotoQuality(commons, current.photoQuality), + removeExifAfterCapture = booleanOrNull(commons, REMOVE_EXIF_AFTER_CAPTURE) + ?: current.removeExifAfterCapture, + gyroscopeSuggestions = booleanOrNull(commons, GYROSCOPE_SUGGESTIONS) + ?: current.gyroscopeSuggestions, + saveImageAsPreviewed = booleanOrNull(commons, SAVE_IMAGE_AS_PREVIEW) + ?: current.saveImageAsPreviewed, + saveVideoAsPreviewed = readSaveVideoAsPreviewed( + commons = commons, + current = current.saveVideoAsPreviewed, + ), + scanAllCodes = booleanOrNull(commons, SCAN_ALL_CODES) ?: current.scanAllCodes, + enabledBarcodeFormats = readEnabledBarcodeFormats(commons) + ?: current.enabledBarcodeFormats, + ) + } + + private fun readGridType(commons: SharedPreferences): StoredGridType? { + val ordinal = intOrNull(commons, GRID) ?: return null + + return LEGACY_GRID_TYPES.getOrNull(ordinal) ?: StoredGridType.NONE + } + + private fun readFocusTimeoutSeconds(commons: SharedPreferences): Long? { + return when (val label = commons.getString(FOCUS_TIMEOUT, null)) { + null -> null + FOCUS_TIMEOUT_OFF -> 0L + else -> label.removeSuffix("s").toLongOrNull() + ?: SettingsDefaults.FOCUS_TIMEOUT_SECONDS + } + } + + // Older versions stored either a quality toggle or an invalid zero from the seek bar. + private fun readPhotoQuality(commons: SharedPreferences, current: Int?): Int? { + val stored = intOrNull(commons, PHOTO_QUALITY) + + return when { + stored != null -> stored.takeIf { it > 0 } ?: SettingsDefaults.PHOTO_QUALITY + booleanOrNull(commons, EMPHASIS_ON_QUALITY) == true -> MAX_PHOTO_QUALITY + commons.contains(EMPHASIS_ON_QUALITY) -> SettingsDefaults.PHOTO_QUALITY + else -> current + } + } + + // Installs predating this setting recorded without preview mirroring. + private fun readSaveVideoAsPreviewed( + commons: SharedPreferences, + current: Boolean?, + ): Boolean? { + val predatesTheSetting = commons.contains(SAVE_IMAGE_AS_PREVIEW) && + !commons.contains(SAVE_VIDEO_AS_PREVIEW) + + return when { + predatesTheSetting -> false + else -> booleanOrNull(commons, SAVE_VIDEO_AS_PREVIEW) ?: current + } + } + + // The default format's key marked the legacy set as explicitly configured. + private fun readEnabledBarcodeFormats(commons: SharedPreferences): Set? { + if (!commons.contains(DEFAULT_SCAN_KEY)) { + return null + } + + return commons.all.keys + .filter { it.startsWith(SCAN_PREFIX) && it != SCAN_ALL_CODES } + .filterTo(mutableSetOf()) { commons.getBoolean(it, false) } + .mapTo(mutableSetOf()) { it.removePrefix(SCAN_PREFIX) } + } + + private fun readModeSettings(current: SettingsPrefs): Map { + var migrated = current + + CameraMode.entries.forEach { mode -> + val modePreferences = preferences(SettingsPrefs.storedModeName(mode)) + + if (modePreferences.all.isNotEmpty()) { + migrated = migrated.withMode( + mode = mode, + settings = readMode( + modePreferences = modePreferences, + current = migrated.mode(mode), + ), + ) + } + } + + return migrated.modes + } + + private fun readMode( + modePreferences: SharedPreferences, + current: StoredModeSettings, + ): StoredModeSettings { + return current.copy( + flashMode = intOrNull(modePreferences, FLASH_MODE) ?: current.flashMode, + geoTagging = booleanOrNull(modePreferences, GEO_TAGGING) ?: current.geoTagging, + selfIllumination = booleanOrNull(modePreferences, SELF_ILLUMINATION) + ?: current.selfIllumination, + videoQualityFront = readVideoQuality( + modePreferences = modePreferences, + key = VIDEO_QUALITY_FRONT, + current = current.videoQualityFront, + ), + videoQualityBack = readVideoQuality( + modePreferences = modePreferences, + key = VIDEO_QUALITY_BACK, + current = current.videoQualityBack, + ), + ) + } + + // The shipped placeholder label fell through to SD, so preserve that migration behavior. + private fun readVideoQuality( + modePreferences: SharedPreferences, + key: String, + current: StoredVideoQuality, + ): StoredVideoQuality { + val title = modePreferences.getString(key, null) + ?: return current + + return LEGACY_VIDEO_QUALITIES[title] ?: UNRECOGNISED_VIDEO_QUALITY + } + + private fun intOrNull(preferences: SharedPreferences, key: String): Int? { + return when { + preferences.contains(key) -> preferences.getInt(key, 0) + else -> null + } + } + + private fun booleanOrNull(preferences: SharedPreferences, key: String): Boolean? { + return when { + preferences.contains(key) -> preferences.getBoolean(key, false) + else -> null + } + } + + private fun preferences(name: String): SharedPreferences { + return context.getSharedPreferences(name, Context.MODE_PRIVATE) + } + + private companion object { + const val ASPECT_RATIO = "aspect_ratio" + const val CAMERA_SOUNDS = "camera_sounds" + const val EMPHASIS_ON_QUALITY = "emphasis_on_quality" + const val ENABLE_EIS = "enable_eis" + const val ENABLE_ZSL = "enable_zsl" + const val FLASH_MODE = "flash_mode" + const val FOCUS_TIMEOUT = "focus_timeout" + const val GEO_TAGGING = "geo_tagging" + const val GRID = "grid" + const val GYROSCOPE_SUGGESTIONS = "gyroscope_suggestions" + const val INCLUDE_AUDIO = "include_audio" + const val PHOTO_QUALITY = "photo_quality" + const val REMOVE_EXIF_AFTER_CAPTURE = "remove_exif_after_capture" + const val SAVE_IMAGE_AS_PREVIEW = "save_image_as_preview" + const val SAVE_VIDEO_AS_PREVIEW = "save_video_as_preview" + const val SCAN_ALL_CODES = "scan_all_codes" + const val SCAN_PREFIX = "scan_" + const val DEFAULT_SCAN_KEY = "scan_QR_CODE" + const val SELECT_HIGHEST_RESOLUTION = "select_highest_resolution" + const val SELF_ILLUMINATION = "self_illumination" + const val SELF_TIMER_DURATION = "self_timer_duration" + const val WAIT_FOR_FOCUS_LOCK = "wait_for_focus_lock" + const val VIDEO_QUALITY_FRONT = "video_quality_FRONT" + const val VIDEO_QUALITY_BACK = "video_quality_BACK" + + const val FOCUS_TIMEOUT_OFF = "Off" + const val MAX_PHOTO_QUALITY = 100 + val UNRECOGNISED_VIDEO_QUALITY = StoredVideoQuality.SD + + // This order is the shipped ordinal wire format. + val LEGACY_GRID_TYPES = listOf( + StoredGridType.NONE, + StoredGridType.THREE_BY_THREE, + StoredGridType.FOUR_BY_FOUR, + StoredGridType.GOLDEN_RATIO, + ) + + val LEGACY_VIDEO_QUALITIES = mapOf( + "2160p (UHD)" to StoredVideoQuality.UHD, + "1080p (FHD)" to StoredVideoQuality.FHD, + "720p (HD)" to StoredVideoQuality.HD, + "480p (SD)" to StoredVideoQuality.SD, + ) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/di/core/CoroutinesProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/core/CoroutinesProvidesModule.kt new file mode 100644 index 000000000..34765dcfd --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/core/CoroutinesProvidesModule.kt @@ -0,0 +1,31 @@ +package app.grapheneos.camera.di.core + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@Module +@InstallIn(SingletonComponent::class) +internal class CoroutinesProvidesModule { + + @Provides + @IoDispatcher + fun provideIoDispatcher(): CoroutineDispatcher { + return Dispatchers.IO + } + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope { + return CoroutineScope(SupervisorJob() + dispatcher) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/di/core/Qualifiers.kt b/app/src/main/java/app/grapheneos/camera/di/core/Qualifiers.kt new file mode 100644 index 000000000..fa53fc46f --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/core/Qualifiers.kt @@ -0,0 +1,15 @@ +package app.grapheneos.camera.di.core + +import javax.inject.Qualifier + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class IoDispatcher + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class ApplicationScope + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class DurablePreferences diff --git a/app/src/main/java/app/grapheneos/camera/di/media/MediaProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/media/MediaProvidesModule.kt new file mode 100644 index 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/DurablePreferencesProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/preferences/DurablePreferencesProvidesModule.kt new file mode 100644 index 000000000..e21f88efa --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/preferences/DurablePreferencesProvidesModule.kt @@ -0,0 +1,86 @@ +package app.grapheneos.camera.di.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStoreFile +import app.grapheneos.camera.data.media.store.MediaPrefs +import app.grapheneos.camera.data.media.store.MediaPrefsMigration +import app.grapheneos.camera.data.media.store.StoragePrefs +import app.grapheneos.camera.data.media.store.StoragePrefsMigration +import app.grapheneos.camera.data.media.store.mediaPrefsSerializer +import app.grapheneos.camera.data.media.store.storagePrefsSerializer +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.data.settings.store.SettingsPrefsMigration +import app.grapheneos.camera.data.settings.store.settingsPrefsSerializer +import app.grapheneos.camera.di.core.ApplicationScope +import app.grapheneos.camera.di.core.DurablePreferences +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope + +@Module +@InstallIn(SingletonComponent::class) +internal class DurablePreferencesProvidesModule { + + @Provides + @Singleton + @DurablePreferences + fun provideDurableSettingsPrefs( + @ApplicationContext context: Context, + @ApplicationScope scope: CoroutineScope, + ): DataStore { + return DataStoreFactory.create( + serializer = settingsPrefsSerializer, + corruptionHandler = ReplaceFileCorruptionHandler { SettingsPrefs() }, + migrations = listOf(SettingsPrefsMigration(context)), + scope = scope, + ) { + context.dataStoreFile(SETTINGS_PREFS_FILE_NAME) + } + } + + @Provides + @Singleton + @DurablePreferences + fun provideDurableStoragePrefs( + @ApplicationContext context: Context, + @ApplicationScope scope: CoroutineScope, + ): DataStore { + return DataStoreFactory.create( + serializer = storagePrefsSerializer, + corruptionHandler = ReplaceFileCorruptionHandler { StoragePrefs() }, + migrations = listOf(StoragePrefsMigration(context)), + scope = scope, + ) { + context.dataStoreFile(STORAGE_PREFS_FILE_NAME) + } + } + + @Provides + @Singleton + fun provideDurableMediaPrefs( + @ApplicationContext context: Context, + @ApplicationScope scope: CoroutineScope, + ): DataStore { + return DataStoreFactory.create( + serializer = mediaPrefsSerializer, + corruptionHandler = ReplaceFileCorruptionHandler { MediaPrefs() }, + migrations = listOf(MediaPrefsMigration(context)), + scope = scope, + ) { + context.dataStoreFile(MEDIA_PREFS_FILE_NAME) + } + } + + private companion object { + private const val SETTINGS_PREFS_FILE_NAME = "settings_prefs.json" + private const val STORAGE_PREFS_FILE_NAME = "storage_prefs.json" + private const val MEDIA_PREFS_FILE_NAME = "media_prefs.json" + } +} diff --git a/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt new file mode 100644 index 000000000..49cbdfadb --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt @@ -0,0 +1,47 @@ +package app.grapheneos.camera.di.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import app.grapheneos.camera.data.core.store.InMemoryDataStore +import app.grapheneos.camera.data.media.store.StoragePrefs +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.di.core.DurablePreferences +import app.grapheneos.camera.ui.activities.SecureActivity +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +@Module +@InstallIn(ActivityComponent::class) +internal class PreferencesProvidesModule { + + @Provides + @ActivityScoped + fun provideSettingsPrefs( + @ActivityContext context: Context, + @DurablePreferences durable: DataStore, + ): DataStore { + return when (context) { + // Secure sessions get a snapshot so later owner changes cannot leak through the lockscreen. + is SecureActivity -> InMemoryDataStore(runBlocking { durable.data.first() }) + else -> durable + } + } + + @Provides + @ActivityScoped + fun provideStoragePrefs( + @ActivityContext context: Context, + @DurablePreferences durable: DataStore, + ): DataStore { + return when (context) { + is SecureActivity -> InMemoryDataStore(runBlocking { durable.data.first() }) + else -> durable + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/di/settings/SettingsBindsModule.kt b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsBindsModule.kt new file mode 100644 index 000000000..2c3db413f --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsBindsModule.kt @@ -0,0 +1,20 @@ +package app.grapheneos.camera.di.settings + +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import app.grapheneos.camera.data.settings.repository.SettingsRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +internal abstract class SettingsBindsModule { + + @Binds + @ActivityScoped + abstract fun bindSettingsRepository( + impl: SettingsRepositoryImpl, + ): SettingsRepository +} diff --git a/app/src/main/java/app/grapheneos/camera/di/settings/SettingsMapperBindsModule.kt b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsMapperBindsModule.kt new file mode 100644 index 000000000..4d813e289 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsMapperBindsModule.kt @@ -0,0 +1,36 @@ +package app.grapheneos.camera.di.settings + +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapper +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapperImpl +import app.grapheneos.camera.data.settings.mapper.ModeSettingsMapper +import app.grapheneos.camera.data.settings.mapper.ModeSettingsMapperImpl +import app.grapheneos.camera.data.settings.mapper.StoredVideoQualityMapper +import app.grapheneos.camera.data.settings.mapper.StoredVideoQualityMapperImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class SettingsMapperBindsModule { + + @Binds + @Reusable + abstract fun bindCameraSettingsMapper( + impl: CameraSettingsMapperImpl, + ): CameraSettingsMapper + + @Binds + @Reusable + abstract fun bindModeSettingsMapper( + impl: ModeSettingsMapperImpl, + ): ModeSettingsMapper + + @Binds + @Reusable + abstract fun bindStoredVideoQualityMapper( + impl: StoredVideoQualityMapperImpl, + ): StoredVideoQualityMapper +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt b/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt index 239ed7b36..91b4db556 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt @@ -7,7 +7,7 @@ import android.view.MotionEvent import android.view.ViewGroup import android.view.animation.AnimationUtils import androidx.core.view.children -import app.grapheneos.camera.CameraMode +import app.grapheneos.camera.data.core.model.CameraMode import com.google.android.material.tabs.TabLayout import kotlin.math.abs import kotlin.math.roundToInt diff --git a/app/src/main/java/app/grapheneos/camera/ui/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..e71dd82fc 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt @@ -11,7 +11,6 @@ import android.graphics.Color import android.graphics.Rect import android.os.Handler import android.os.Looper -import android.util.Log import android.view.Gravity import android.view.MotionEvent import android.view.View @@ -25,7 +24,6 @@ import android.widget.ArrayAdapter import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout -import android.widget.RadioGroup import android.widget.ScrollView import android.widget.Spinner import android.widget.ToggleButton @@ -41,14 +39,13 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import androidx.core.view.ViewCompat -import app.grapheneos.camera.CamConfig import app.grapheneos.camera.R +import app.grapheneos.camera.data.settings.model.GridType import app.grapheneos.camera.databinding.SettingsBinding import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.MoreSettings import com.google.android.material.color.MaterialColors import com.google.android.material.materialswitch.MaterialSwitch -import com.google.android.material.radiobutton.MaterialRadioButton import java.util.Collections import kotlin.math.max @@ -65,7 +62,9 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : var torchToggle: ToggleButton private var gridToggle: ImageView var videoQualitySpinner: Spinner - private lateinit var vQAdapter: ArrayAdapter + internal var videoQualities: List = emptyList() + private set + private var focusTimeoutSpinner: Spinner private var timerSpinner: Spinner @@ -219,10 +218,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() } @@ -237,9 +236,9 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : position: Int, p3: Long ) { + val quality = videoQualities.getOrNull(position) ?: return - val choice = vQAdapter.getItem(position) as String - updateVideoQuality(choice) + updateVideoQuality(quality) } override fun onNothingSelected(p0: AdapterView<*>?) {} @@ -550,18 +549,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) @@ -570,10 +564,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : timerSpinner.setSelection(timeOptions.indexOf(option).coerceAtLeast(0), false) } - fun updateVideoQuality(choice: String, resCam: Boolean = true) { - - val quality = titleToQuality(choice) - + fun updateVideoQuality(quality: Quality, resCam: Boolean = true) { if (quality == camConfig.videoQuality) return camConfig.videoQuality = quality @@ -581,21 +572,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : if (resCam) { camConfig.startCamera(true) } else { - videoQualitySpinner.setSelection(getAvailableQTitles().indexOf(choice)) - - } - } - - 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 - } + videoQualitySpinner.setSelection(videoQualities.indexOf(quality)) } } @@ -785,38 +762,15 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : return Recorder.getVideoCapabilities(cameraInfo).getSupportedQualities(DynamicRange.SDR) } - private fun getAvailableQTitles(): List { - val titles = arrayListOf() - - getAvailableQualities().forEach { - titles.add(getTitleFor(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) @@ -876,23 +830,22 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } fun reloadQualities() { + videoQualities = getAvailableQualities() - val titles = getAvailableQTitles() - - vQAdapter = ArrayAdapter( + val adapter = ArrayAdapter( mActivity, android.R.layout.simple_spinner_item, - titles + videoQualities.map { videoQualityTitle(mActivity, it) }, ) - vQAdapter.setDropDownViewResource( + adapter.setDropDownViewResource( android.R.layout.simple_spinner_dropdown_item ) - videoQualitySpinner.adapter = vQAdapter + videoQualitySpinner.adapter = adapter if (camConfig.videoQuality != Quality.HIGHEST) { - videoQualitySpinner.setSelection(titles.indexOf(getTitleFor(camConfig.videoQuality))) + videoQualitySpinner.setSelection(videoQualities.indexOf(camConfig.videoQuality)) } } } diff --git a/app/src/main/java/app/grapheneos/camera/ui/VideoQualityTitle.kt b/app/src/main/java/app/grapheneos/camera/ui/VideoQualityTitle.kt new file mode 100644 index 000000000..9c45167f7 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/VideoQualityTitle.kt @@ -0,0 +1,17 @@ +package app.grapheneos.camera.ui + +import android.content.Context +import androidx.camera.video.Quality +import app.grapheneos.camera.R + +fun videoQualityTitle(context: Context, quality: Quality): String { + val titleId = when (quality) { + Quality.UHD -> R.string.video_quality_uhd + Quality.FHD -> R.string.video_quality_fhd + Quality.HD -> R.string.video_quality_hd + Quality.SD -> R.string.video_quality_sd + else -> R.string.video_quality_unknown + } + + return context.getString(titleId) +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt index 49828bb26..209f4c24a 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt @@ -39,11 +39,11 @@ import androidx.viewpager2.widget.ViewPager2 import androidxc.exifinterface.media.ExifInterface import app.grapheneos.camera.AutoFinishOnSleep import app.grapheneos.camera.CapturedItem -import app.grapheneos.camera.CapturedItems import app.grapheneos.camera.GSlideTransformer import app.grapheneos.camera.GallerySliderAdapter import app.grapheneos.camera.ITEM_TYPE_VIDEO import app.grapheneos.camera.R +import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.databinding.GalleryBinding import app.grapheneos.camera.editCapturedItem import app.grapheneos.camera.shareCapturedItem @@ -53,16 +53,23 @@ import app.grapheneos.camera.util.getParcelableExtra import app.grapheneos.camera.util.storageLocationToUiString import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint import java.text.ParseException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone import java.util.concurrent.Executors +import javax.inject.Inject import kotlin.properties.Delegates +import kotlinx.coroutines.runBlocking +@AndroidEntryPoint class InAppGallery : AppCompatActivity() { + @Inject + lateinit var capturedItemRepository: CapturedItemRepository + lateinit var binding: GalleryBinding lateinit var gallerySlider: ViewPager2 var gallerySliderAdapter: GallerySliderAdapter? = null @@ -661,7 +668,7 @@ class InAppGallery : AppCompatActivity() { asyncLoaderOfCapturedItems.execute { val unprocessedItems: List = try { - CapturedItems.get(this) + runBlocking { capturedItemRepository.capturedItems() } } catch (_: InterruptedException) { // activity was destroyed and exectutor.shutdownNow() was called, which interrupts // executor threads diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt index ea82300fa..147daf737 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt @@ -71,13 +71,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 @@ -104,11 +106,13 @@ import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayout import com.google.zxing.BarcodeFormat +import dagger.hilt.android.AndroidEntryPoint import java.io.File import java.nio.charset.StandardCharsets import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject import kotlin.math.abs import kotlin.math.max import kotlin.math.roundToInt @@ -117,6 +121,7 @@ import androidx.core.net.toUri import androidx.core.view.isVisible import androidx.core.graphics.createBitmap +@AndroidEntryPoint open class MainActivity : AppCompatActivity(), OnTouchListener, OnScaleGestureListener, @@ -124,6 +129,12 @@ open class MainActivity : AppCompatActivity(), GestureDetector.OnDoubleTapListener, SensorOrientationChangeNotifier.Listener { + @Inject + lateinit var settingsRepository: SettingsRepository + + @Inject + lateinit var capturedItemRepository: CapturedItemRepository + private val application: App get() = applicationContext as App @@ -714,7 +725,7 @@ open class MainActivity : AppCompatActivity(), } if (this !is SecureActivity) { - camConfig.fetchLastCapturedItemFromSharedPrefs() + camConfig.fetchLastCapturedItem() } updateThumbnail() @@ -773,7 +784,11 @@ open class MainActivity : AppCompatActivity(), gestureDetector = GestureDetector(this, this) - camConfig = CamConfig(this) + camConfig = CamConfig( + mActivity = this, + settingsRepository = settingsRepository, + capturedItemRepository = capturedItemRepository, + ) cameraControl = CameraControl(camConfig) mainOverlay = binding.mainOverlay imageCapturer = ImageCapturer(this) @@ -1894,6 +1909,7 @@ open class MainActivity : AppCompatActivity(), SensorOrientationChangeNotifier.clearInstance() thumbnailLoaderExecutor.shutdownNow() frameCopyThread?.quitSafely() + camConfig.onDestroy() } fun locationCamConfigChanged(required: Boolean) { diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt index ef5c03b77..a8c969af4 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt @@ -23,6 +23,7 @@ import app.grapheneos.camera.CamConfig import app.grapheneos.camera.CapturedItems import app.grapheneos.camera.NumInputFilter import app.grapheneos.camera.R +import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.databinding.MoreSettingsBinding import app.grapheneos.camera.util.storageLocationToUiString import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -127,7 +128,7 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { dialog.setMessage(R.string.revert_to_default_directory) dialog.setPositiveButton(R.string.yes) { _, _ -> - val defaultLocation = CamConfig.SettingValues.Default.STORAGE_LOCATION + val defaultLocation = CapturedItemRepository.MEDIA_STORE_LOCATION if (camConfig.storageLocation != defaultLocation) { showMessage(getString(R.string.reverted_to_default_directory)) diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettingsSecure.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettingsSecure.kt index 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/SharedPrefs.kt deleted file mode 100644 index 9da49d2e5..000000000 --- a/app/src/main/java/app/grapheneos/camera/util/SharedPrefs.kt +++ /dev/null @@ -1,166 +0,0 @@ -package app.grapheneos.camera.util - -import android.annotation.SuppressLint -import android.content.Context -import android.content.SharedPreferences -import android.os.Build -import android.util.ArrayMap -import java.util.WeakHashMap - -import android.content.SharedPreferences.OnSharedPreferenceChangeListener as ChangeListener - -typealias EphemeralSharedPrefsNamespace = ArrayMap - -fun EphemeralSharedPrefsNamespace.getPrefs(ctx: Context, name: String, mode: Int, cloneOriginal: Boolean): SharedPreferences { - require(mode == Context.MODE_PRIVATE) - synchronized(this) { - return getOrElse(name) { - val prefs = EphemeralSharedPrefs(ctx.applicationInfo.targetSdkVersion) - - if (cloneOriginal) { - val orig = ctx.applicationContext.getSharedPreferences(name, Context.MODE_PRIVATE) - orig.all.forEach { k, v -> - prefs.map[k] = v - } - } - - this[name] = prefs - - prefs - } - } -} - -class EphemeralSharedPrefs(val targetSdk: Int) : SharedPreferences { - - internal val map = HashMap() - // match the "weakly referenced listeners" behavior of the regular SharedPreferences, - // there's no WeakSet, approximate it by using a dummy value - internal val listeners = WeakHashMap() - - override fun getAll(): MutableMap = map - - @Suppress("UNCHECKED_CAST") - private fun get(key: String?, defValue: T?): T? { - synchronized(this) { - return map[key!!] as T ?: defValue - } - } - - override fun getString(key: String?, defValue: String?): String? = get(key, defValue) - override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = get(key, defValues) - override fun getInt(key: String?, defValue: Int): Int = get(key, defValue) ?: defValue - override fun getLong(key: String?, defValue: Long): Long = get(key, defValue) ?: defValue - override fun getFloat(key: String?, defValue: Float): Float = get(key, defValue) ?: defValue - override fun getBoolean(key: String?, defValue: Boolean): Boolean = get(key, defValue) ?: defValue - - override fun contains(key: String?): Boolean = map.contains(key) - - override fun edit(): SharedPreferences.Editor = Editor(this) - - override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { - synchronized(this) { - listeners[listener] = this - } - } - - override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { - synchronized(this) { - listeners.remove(listener) - } - } - - private class Editor(val prefs: EphemeralSharedPrefs) : SharedPreferences.Editor { - val map = HashMap() - private val removedKeys = arrayListOf() - val thread = Thread.currentThread() - - private var cleared = false - - private fun checkThread() { - check(Thread.currentThread() === thread) - } - - private fun put(key: String?, value: T?): Editor { - checkThread() - map[key!!] = value - return this - } - - override fun putString(key: String?, value: String?) = put(key, value) - override fun putStringSet(key: String?, values: MutableSet?) = put(key, values) - override fun putInt(key: String?, value: Int) = put(key, value) - override fun putLong(key: String?, value: Long) = put(key, value) - override fun putFloat(key: String?, value: Float) = put(key, value) - override fun putBoolean(key: String?, value: Boolean) = put(key, value) - - override fun remove(key: String?): SharedPreferences.Editor { - checkThread() - removedKeys.add(key!!) - return this - } - - override fun clear(): SharedPreferences.Editor { - checkThread() - cleared = true - return this - } - - override fun commit(): Boolean { - apply() - return true - } - - override fun apply() { - checkThread() - val listeners: Set - - synchronized(prefs) { - listeners = prefs.listeners.keys - - if (cleared) { - prefs.map.clear() - } - removedKeys.forEach { key -> - prefs.map.remove(key) - } - map.forEach { k, v -> - prefs.map[k] = v - } - } - - // notify listeners outside the critical section - - if (cleared) { - // see onSharedPreferenceChanged() doc - if (prefs.targetSdk >= Build.VERSION_CODES.R) { - listeners.forEach { - it.onSharedPreferenceChanged(prefs, null) - } - } - } - removedKeys.forEach { key -> - listeners.forEach { - it.onSharedPreferenceChanged(prefs, key) - } - } - map.forEach { k, _ -> - listeners.forEach { - it.onSharedPreferenceChanged(prefs, k) - } - } - } - } -} - -@SuppressLint("ApplySharedPref") -inline fun SharedPreferences.edit(commit: Boolean = false, - action: SharedPreferences.Editor.() -> Unit) { - val editor = edit() - action(editor) - if (commit) { - editor.commit() - } else { - editor.apply() - } -} diff --git a/app/src/main/java/app/grapheneos/camera/util/Utils.kt b/app/src/main/java/app/grapheneos/camera/util/Utils.kt index 34529b0b6..416014fce 100644 --- a/app/src/main/java/app/grapheneos/camera/util/Utils.kt +++ b/app/src/main/java/app/grapheneos/camera/util/Utils.kt @@ -11,6 +11,7 @@ import app.grapheneos.camera.CamConfig import app.grapheneos.camera.R import app.grapheneos.camera.capturer.DEFAULT_MEDIA_STORE_CAPTURE_PATH import app.grapheneos.camera.capturer.SAF_URI_HOST_EXTERNAL_STORAGE +import app.grapheneos.camera.data.media.repository.CapturedItemRepository import java.io.ByteArrayOutputStream import java.io.IOException import java.io.PrintStream @@ -51,7 +52,7 @@ fun ExecutorService.executeIfAlive(r: Runnable) { } fun storageLocationToUiString(ctx: Context, sl: String): String { - if (sl == CamConfig.SettingValues.Default.STORAGE_LOCATION) { + if (sl == CapturedItemRepository.MEDIA_STORE_LOCATION) { return "${ctx.getString(R.string.main_storage)}/$DEFAULT_MEDIA_STORE_CAPTURE_PATH" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8df97c257..af61a0a50 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -150,6 +150,12 @@ does not suggest rebooting. --> This mode is unavailable on this device. + 2160p (UHD) + 1080p (FHD) + 720p (HD) + 480p (SD) + Unknown +