diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..4d6ec2ba --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +indent_size = 4 +indent_style = space +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_packages_to_use_import_on_demand = unset +ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1 +ij_kotlin_line_break_after_multiline_when_entry = false +ktlint_code_style = android_studio +ktlint_function_naming_ignore_when_annotated_with = Composable +ktlint_standard_filename = disabled +ktlint_standard_function-expression-body = disabled +ktlint_standard_function-signature = disabled +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_blank-line-between-when-conditions = disabled +max_line_length = 100 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1dfd8d9f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,430 @@ +# Agents Guidelines — Camera + +Shared guidelines for all AI coding agents working on GrapheneOS Camera. +`CLAUDE.md` and `GEMINI.md` are symlinks to this file — edit this one. + +--- + +## Project Overview + +Android camera app built on CameraX. Single `:app` module of app Kotlin plus vendored AndroidX +Java under `androidxc/` (do not modify or restyle). The app is migrating incrementally from +Views/XML to Compose. + +**This repository exists to raise PRs against upstream GrapheneOS Camera.** Every change must stand +on its own merits to a reviewer with no context beyond the diff. No big-bang rewrites. + +### Key Coordinates + +| Key | Value | +|-------------|-----------------------------------------------| +| Package | `app.grapheneos.camera` | +| minSdk | 29 — the one that constrains API choices | +| targetSdk | tracks compileSdk | +| Build types | `debug` (`.dev`), `release`, `play` (`.play`) | +| Toolchain | JDK 17 (CI runs Gradle itself on a newer JDK) | + +Versions live in `gradle/libs.versions.toml` and `gradle/wrapper/gradle-wrapper.properties`. Read +them there — they are the source of truth, and a number copied into this document is a number that +will be wrong. + +### Target Layout + +The current tree is flat Views-era code — read it, don't memorize it from here. **New code lands in +this shape**; anything extracted or rewritten moves toward it, never away: + +Each layer splits per feature, and each feature splits by role: + +``` +app/src/main/java/app/grapheneos/camera/ + data/ + settings/ + model/ CameraSettings, per-mode setting values + repository/ SettingsRepository (entry-mode-scoped, never application-scoped) + store/ prefs-backed stores, EphemeralSharedPrefs namespace + camera/ + model/ CameraCapabilities, lens/extension descriptors + repository/ CameraProviderSource + store/ ExtensionAvailabilityStore + media/ + model/ CapturedItem and friends + repository/ CapturedItemStore + store/ MediaStoreDataSource, SafDataSource + location/ + repository/ LocationRepository + domain/ + camera/usecase/ bind, rebind, lens/flash/zoom/focus + capture/usecase/ capture image, start/stop/pause recording + qr/usecase/ barcode scanning + gallery/usecase/ share, edit, delete (the guarded variants from CapturedItems.kt) + ui/ + core/ Theme.kt, Preview.kt + common/components/ composables shared across screens + viewfinder/ + screen/ ViewfinderScreen, ViewfinderViewModel, ViewfinderEffectHandler + model/ ViewfinderUiState, ViewfinderAction, ViewfinderScreenEffect, NavEvent + mapper/ domain → UiState mappers + components/ CaptureButton, ModeTabStrip, ZoomSlider, GridOverlay, FocusRing, ... + gallery/ same screen/{model,mapper} + components/ shape + videoplayer/ " + settings/ " (viewfinder settings sheet) + moresettings/ " + di/ + core/ app-wide modules, qualifiers + / one module package per feature (camera, capture, gallery, ...) +``` + +Roles: `model/` = plain data types, `repository/` = the feature's public data API, +`store/` = persistence/platform sources behind it, `mapper/` = pure transformation functions, +`usecase/` = one verb per class. A package appears when its first class does — don't pre-create +empty directories. + +`app/src/main/java/androidxc/` is vendored AndroidX Java — do not modify. + +### Activity entry points + +``` +MainActivity ← SecureMainActivity ← QrTile + ← VideoOnlyActivity + ← CaptureActivity ← SecureCaptureActivity + ← VideoCaptureActivity +``` + +Plus `InAppGallery`, `VideoPlayer`, `MoreSettings ← MoreSettingsSecure`, and the `CameraLauncher` +activity-alias. The inheritance chain is today's configuration mechanism — it is how each entry +point differs. Treat any change to it as a change to the manifest contract. + +--- + +## Build & Run + +```sh +./gradlew :app:compileDebugKotlin # fast check — run this after writing Kotlin +./gradlew :app:assembleDebug # debug APK +./gradlew build --no-daemon # what CI runs +./gradlew :app:dependencies # after touching build files — check what CameraX resolved to +``` + +**The debug build installs as `app.grapheneos.camera.dev`.** The plain `app.grapheneos.camera` +package is the stock system app that ships with the OS. Install with `./gradlew installDebug` and +verify against `.dev` — verifying against the stock package makes a working change look dead. + +--- + +## Testing + +| Suite | Location | Command | Device | +|------------------|------------------------|--------------------------------------------|:------:| +| **Instrumented** | `app/src/androidTest/` | `./gradlew :app:connectedDebugAndroidTest` | yes | +| **Unit** | `app/src/test/` | `./gradlew :app:testDebugUnitTest` | no | + +The instrumented tests are Espresso/UiAutomator against the View hierarchy. +**Each one encodes a real incident** — video double-start crashes, SAF grant `SecurityException`s, +extension bind `UnsupportedOperationException`s, gallery NPEs. + +- **Never delete a regression test without its replacement in the same commit.** +- Run a single class with: + ```sh + ./gradlew :app:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=app.grapheneos.camera.VideoCapturerRegressionTest + ``` +- **Known flake:** + `VideoCapturerRegressionTest.leavingACaptureSessionWhileRecording_defersThePreview` + fails only in full-suite runs, and does so on unmodified `main` too. Re-run it alone before + attributing the failure to your diff. + +--- + +## Architecture + +### Legacy + +The pre-migration code has no DI, no ViewModels, no coroutines in the camera path (raw +`thread {}`, `Executors`, `Handler`). +`CamConfig` holds `private val mActivity: MainActivity` and some of its properties read the View +tree directly (e.g. `requireLocation`'s getter returns +`mActivity.settingsDialog.locToggle.isChecked`). This coupling is the thing the migration exists to +undo — do not add to it. + +### Target + +Compose + Hilt + per-screen unidirectional data flow + `data`/`domain`/`ui` layering; Material3 +Expressive styling. +Strategy is foundation-first: extract a testable domain layer underneath the existing Views +(keeping the instrumented regression suite green *and unmodified*), then replace the UI one screen +at a time — **leaf screens first, viewfinder last**. + +### Architectural rules (new code) + +Every migrated feature follows the same shape — when in doubt, open an already-migrated feature in +this repo and copy it. + +**Layering.** Dependency direction is `ui → domain → data`; `data` and `domain` never import `ui`, +and nothing below `ui` touches Compose or an Activity. + +**Features are siblings, not dependencies.** A feature package imports its own layers plus shared +`core`/`common` code — never another feature. What two features both need moves down into a shared +package rather than being reached across for. This is what keeps a later module split a directory +move instead of an untangling. + +**Everything injectable is an interface + `Impl` pair.** Callers depend on `interface +PhotosRepository`; the implementation is `internal class PhotosRepositoryImpl` bound to it in a DI +module. Both live in the **same file, named after the interface** (`PhotosRepository.kt`). This +holds for repositories, use cases, mappers, effect handlers — anything that gets injected — so +every dependency can be faked in tests and previews. The one naming exception is ViewModels: the +interface is `ScreenModel` and the implementation is `ViewModel` (no `Impl`), both in +`ViewModel.kt` — see the screen contract below. + +Roles: + +- **Repository** (`data//repository/`): the feature's public data API. Exposes `Flow`s + and `suspend` functions; applies `flowOn(dispatcher)` itself so callers never think about + threads. +- **Use case** (`domain//usecase/`): one verb per class, named as the verb + (`ShareCapturedItem`), interface exposing `suspend operator fun invoke(...)`. Returns a + sealed result type from `domain//model/`, not exceptions. +- **Mapper**: pure `map(input): output` — no side effects, no Context. +- Dispatchers are injected via qualifiers (`@IoDispatcher`, `@DefaultDispatcher`) declared in + `di/core/`, never referenced as `Dispatchers.IO` inline. +- **DI** (`di//`): one `@Module @InstallIn(SingletonComponent::class)` abstract class per + feature with `@Binds @Reusable` for each interface→Impl pair. Everything is `internal`. + +**Unidirectional data flow per screen** (`ui//screen/`): + +- The ViewModel implements a `ScreenModel` interface exposing exactly + `uiState: StateFlow`, `effects: Flow`, `onAction(Action)`. The screen + composable takes the **interface** (defaulted to `viewModel<...>()`), so previews and tests + substitute a fake without Hilt. The screen collects `uiState` with + `collectAsStateWithLifecycle()` — never plain `collectAsState()`. +- `UiState` (`screen/model/`): `@Immutable` data class, every field defaulted so `State()` is the + loading state; lists are `kotlinx.collections.immutable.ImmutableList`. Nested per-item types are + `UiModel`s in the same package, built by a `screen/mapper/` UiStateMapper. +- `Action`: sealed interface of user events, named past-tense from the UI's point of view + (`ShutterClicked`, `LensSwitchClicked`) — never imperative commands. The ViewModel's `onAction` + is a single exhaustive `when`. +- `ScreenEffect`: sealed interface of one-shot events, emitted through + `Channel(capacity = Channel.BUFFERED)` exposed as `receiveAsFlow()` — never a StateFlow, which + would replay. Navigation is its own `NavEvent` sealed type (or an `onNavigateBack`-style lambda + for simple back). +- **EffectHandler** (`screen/`): interface + `Impl` constructed with the Activity — the *only* + place intents, toasts, clipboard, and `finish()` live. The screen collects + `screenModel.effects` in a `LaunchedEffect(screenModel)` and forwards to the handler via + `rememberUpdatedState`. For Camera this is where the security-sensitive behavior concentrates: + the handler holds the real Activity, so prefs stay entry-mode-scoped and intent launches stay + behind `QrTile`'s keyguard interceptor by construction. +- Screen file shape: public `Screen` wires the model and effects; a private, stateless + `Content(uiState, onAction, ...)` renders it; `@PreviewLightDark` previews call `Content` + with literal state. In-file aliases keep signatures readable: + `import ...model.ViewfinderAction as Action`. +- A ViewModel that outgrows one file splits into `delegate/` classes by responsibility + (selection, optimistic updates, ...), not into a bigger ViewModel. + +--- + +## Coding Conventions + +These govern **new and rewritten code**. Existing files predate them; do not reformat a file you are +not otherwise changing — whitespace churn buries the diff and makes the migration unreviewable. + +### Kotlin + +- **No expression-body functions.** Always a block body with an explicit return type: + ```kotlin + // WRONG + fun currentMode() = camConfig.currentMode + + // CORRECT + fun currentMode(): CameraMode { + return camConfig.currentMode + } + ``` + Return type is omitted for functions returning `Unit`; write `fun bind() {`, not + `fun bind(): Unit {`. +- **No fully-qualified names in code.** Import the type and use the short name. Qualify only to + resolve an import conflict. +- **Named arguments** for Kotlin calls — constructors, factories, builders. Exceptions: unambiguous + single-argument calls (`listOf(item)`, `launch(defaultDispatcher)`), stdlib higher-order functions + (`map { }`, `filter { }`), and Java interop. +- **Descriptive names, no abbreviations.** `context` not `ctx`, `manager` not `mgr`. Short names are + fine only when universally unambiguous: `id`, `uri`, `i`/`j` in tight loops, `{ it }`. +- **Parameter formatting:** one line if it fits; otherwise one parameter per line with a trailing + comma. Same for call sites. +- **Trailing commas** in every multi-line parameter list, argument list, `when` branch list and + collection literal. Never on a single line. +- **Never break the line after `=`.** The right-hand side starts on the same line as the assignment; + wrap inside it. Breaking after `=` costs a line and an indent level and separates the name from + the thing that produces it — ktlint's `multiline-expression-wrapping` would impose it, which is + one reason this project's `.editorconfig` selects `android_studio` over `ktlint_official`. + + ```kotlin + // WRONG + val info = + packageManager.getActivityInfo( + ComponentName(context.packageName, "$PACKAGE.ui.activities.QrTile"), + PackageManager.MATCH_ALL, + ) + + // CORRECT + val info = packageManager.getActivityInfo( + ComponentName(context.packageName, "$PACKAGE.ui.activities.QrTile"), + PackageManager.MATCH_ALL, + ) + + // CORRECT — when the call itself does not fit, break the chain instead + val info = packageManager + .getActivityInfo( + ComponentName(context.packageName, "$PACKAGE.ui.activities.QrTile"), + PackageManager.MATCH_ALL, + ) + ``` + + Break after `=` only when nothing else fits — a `when`/`if` expression body, or a single call whose + own name already overruns the line. +- **Never `!!` outside tests.** Prefer `?.`, `?:`, and `requireNotNull(x) { "why" }`. +- **Explicit dispatcher on every `scope.launch(...)`.** Never rely on the scope's implicit + dispatcher. Pass it positionally, not as `context = ...`. +- **`internal` by default** for anything not needed outside the module; `private` aggressively for + implementation details. +- **No wildcard imports.** +- **Top-level declarations are for genuinely shared, standalone things.** A constant, function, or + extension function that relates to a specific class/interface — or is `private` to its file — + belongs inside that class (or its `companion object`), not at top level. Reserve the top level + for declarations with no owning type. +- **Constants** are `private const val` in `UPPER_SNAKE_CASE` — in a `companion object` placed last + in the class body when they relate to a class, at file top level only otherwise. +- **Prefer top-level functions over `object`.** Use `object` only for a genuine stateful singleton + or + to implement an interface. +- **Prefer `when` over `if` for value-producing expressions** — `val x = when {`, not + `val x = if (`. +- **Functions stay focused and compact**, with **no more than 2 `return`s**. +- **Shared helpers take an explicit `activity`/`context` parameter — do not write them as `Activity` + extensions.** `CapturedItems.kt`'s `shareCapturedItem(activity, item)` is the pattern to follow. + An extension hides which Activity a call is scoped to; the secure-session prefs isolation and + `QrTile`'s keyguard interceptor both depend on that being visible at the call site. +- **Best-practice verification:** if you are not certain about a framework or API behavior, check + current official documentation before changing it. CameraX in particular has moved a great deal. + +### Comments + +**The default is no comment.** Code that needs prose to be understood is code that needs rewriting: +a clearer name, a smaller function, or a named intermediate `val` solves more comprehension problems +than any sentence placed above the line. Reach for one of those first, every time. + +A comment earns its place only by carrying what the code cannot — **why**, never **what**. Before +writing one, say what a reader loses if it is deleted. If that answer is a paraphrase of the code, +it is not an answer; delete the comment. + +Worth writing: + +- A constraint from outside the file — a platform or OEM bug, an API that documents one thing and + does another, an ordering the framework requires. These are invisible in the code and expensive to + rediscover. +- Why the obvious approach was rejected, where a reader would otherwise "fix" it back. +- KDoc on a public interface whose contract its signature does not convey: what a caller may assume, + what it must not. +- A `TODO`/`FIXME` naming the condition that resolves it. + +Not worth writing: + +- Restating the next line, the signature, or the type. +- Section banners, decorative rules, `// endregion` scaffolding. +- Narrating the edit rather than the code — "now handles X", "moved from Y", "new". The diff and the + commit message carry history; a comment describes the code as it stands. +- Explaining language or framework basics, or restating a rule from this document. + +Two consequences worth stating outright. Comment density is not a quality signal and a comment is +not a way to show work — a file whose every comment is a *why* reads faster than one where each +comment must be checked against the code to find the two that matter. And a comment that has drifted +out of true is worse than no comment: when you change a line, the comments above it are part of that +change. + +### Testability + +Design new code so its behavior is unit-testable without a device — that is the whole +payoff of the migration: + +- Extract interfaces for data sources and repositories so they can be faked. +- Anything holding business logic must be constructible without an Android `Context`; inject + dependencies through the constructor. +- Prefer pure functions for mappers and state transitions. +- Camera bind ordering is order-sensitive. Settings that trigger a rebind stay **synchronous + write-through** — StateFlow-collector-driven rebinds conflate and reorder emissions. + +### Compose + +- **Material 3 only.** Colors, typography and shapes all come from `CameraTheme` / + `MaterialTheme` — never a hardcoded color, and corners come from `MaterialTheme.shapes`, not an + inline `RoundedCornerShape`. +- **Dynamic color first.** The theme uses the user's device colors (`dynamicDarkColorScheme` / + `dynamicLightColorScheme`). Introduce a custom color only when a real need can't be met by an + existing `MaterialTheme.colorScheme` role, and add it as a theme extension — not inline in a + composable. +- **State hoisting:** composables below screen level are stateless, receiving state as parameters + and + emitting events via lambdas. No ViewModel access below screen level. +- **`modifier: Modifier = Modifier`** as the first optional parameter; chain modifiers, never + reassign. Pass it to the outermost layout the composable emits, exactly once — a composable that + drops its `modifier` or applies it to an inner child breaks its callers' layout expectations. +- **`LaunchedEffect` keys** are stable inputs only — wrap changing callbacks in + `rememberUpdatedState` rather than keying on them. +- One primary public composable per file, `PascalCase`, file named after it. `@Preview` functions + stay in the file that declares the composable they preview. + +### Resources + +User-visible strings go in `res/values/strings.xml` — never hardcoded in Kotlin. Dimensions shared +with XML layouts live in `dimens.xml`; in Compose use `dp`/`sp` directly. + +**Deleting a layout means deleting its resources.** When an XML layout goes away, sweep `values/` +for the strings, dimens, styles and colors only it referenced and remove them in the same commit — +migrating the UI is exactly when they stop being reachable, and left behind they read as live. + +--- + +## Dependencies + +`gradle/libs.versions.toml` is the single source of truth. **Never put a raw version string in a +`build.gradle.kts`.** + +- **Do not add a dependency before something uses it.** Every task here is an upstream PR, and "adds + a dependency nothing references" is the shape of PR a maintainer rejects — correctly. Each + dependency lands in the change whose code first needs it. +- Keep version, library and plugin lists **sorted case-insensitively**; blank-line groups (runtime, + test, tooling) are fine, each sorted internally. +- **CameraX is strictly pinned.** The app imports three CameraX `internal` APIs that carry no + compatibility guarantee, so a bump can break capture *at runtime* while CI stays green. The + catalog uses `strictly` so that a bump fails resolution instead. Read the comment on `camerax` in + `gradle/libs.versions.toml` before touching it; replacing the three imports with supported + equivalents is its own change, and comes first. +- **Dependency hash verification is enforced** via `gradle/verification-metadata.xml` — every + artifact's checksum is pinned, so any new or changed dependency fails the build until its hashes + are recorded there. On a verification error: **stop and ask the user to fix it.** Do not edit + `verification-metadata.xml`, regenerate it, or pass `--write-verification-metadata` yourself — + the whole point of the file is that a human vouches for each hash. + +--- + +## File Naming + +| Type | Convention | Example | +|---------------|----------------------------------------------------|---------------------------------| +| Kotlin source | PascalCase | `VideoCapturer.kt` | +| Injectable | Named after the interface; `Impl` in the same file | `PhotosRepository.kt` | +| Composable | PascalCase, matches composable | `CaptureButton.kt` | +| UI state | PascalCase + `UiState` | `ViewfinderUiState.kt` | +| Extensions | PascalCase + `Extensions` | `SharedPrefsExtensions.kt` | +| Test | Subject + `RegressionTest`/`Test` | `PhotoQualityRegressionTest.kt` | +| Resources | snake_case | `settings_dialog.xml` | + +--- + +## Misc + +- **Do not commit unless the user explicitly asks.** Never `git push` unasked. +- **Never add a commit co-author unless the user explicitly asks.** +- Commit messages: imperative mood, describing the behavior change rather than the mechanism — + match the existing log ("Don't initialize the camera while its permission is not granted"). +- Test-facing seams in `CamConfig` (`mPlayer`, `photoQuality`, `camera`, `switchMode`, + `SettingValues`) are written to by the instrumented suite. They stay writable until the screen + that owns them is migrated. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4becedc2..9bd88bf6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,8 @@ +import dev.detekt.gradle.Detekt +import dev.detekt.gradle.DetektCreateBaselineTask import java.io.FileInputStream import java.util.Properties +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile val keystorePropertiesFile = rootProject.file("keystore.properties") val useKeystoreProperties = keystorePropertiesFile.canRead() @@ -10,6 +13,62 @@ if (useKeystoreProperties) { plugins { alias(libs.plugins.android.application) + alias(libs.plugins.detekt) +} + +detekt { + basePath.set(rootDir) + baseline = file("detekt-baseline.xml") + buildUponDefaultConfig = true + config.setFrom(rootProject.file("config/detekt/detekt.yml")) + ignoredBuildTypes = listOf("release") + parallel = true +} + +// detekt's classpath convention is the compilation's dependencies and nothing else, so BuildConfig +// and androidxc/ resolve to nothing and every type-aware rule goes quiet instead of reporting. A +// Gradle convention cannot be appended to: `from` would discard it, hence `setFrom` with both. +fun addOwnClassesToDetektClasspath( + classpath: ConfigurableFileCollection, + variantName: String, +) { + classpath.setFrom( + tasks.named("compile${variantName}Kotlin").map { it.libraries }, + tasks.named("compile${variantName}JavaWithJavac").map { it.outputs.files }, + ) +} + +// Only the variants `check` gates on below. The plugin's other detekt tasks analyse a source set +// at a time without types and have no compilation to take a classpath from. +listOf("Debug", "DebugUnitTest", "DebugAndroidTest").forEach { variantName -> + tasks + .withType() + .matching { it.name == "detekt$variantName" } + .configureEach { + addOwnClassesToDetektClasspath(classpath, variantName) + } + + tasks + .withType() + .matching { it.name == "detektBaseline$variantName" } + .configureEach { + addOwnClassesToDetektClasspath(classpath, variantName) + } +} + +// The aggregate `detekt` task analyses every source set at once without type resolution, so it +// cannot see what the type-aware rules exist for. The debug variants cover the same sources with +// types, so `check` gates on those and the aggregate stays off. +tasks.named("check") { + dependsOn( + tasks.named("detektDebug"), + tasks.named("detektDebugUnitTest"), + tasks.named("detektDebugAndroidTest"), + ) +} + +tasks.named("detekt") { + enabled = false } java { @@ -89,6 +148,14 @@ android { androidResources { localeFilters += listOf("en") } + + testOptions { + unitTests { + // Robolectric builds its application under test from the merged manifest and + // resources; without this it cannot start one. + isIncludeAndroidResources = true + } + } } dependencies { @@ -101,6 +168,10 @@ dependencies { implementation(libs.zxing.core) + testImplementation(libs.junit4) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core.ktx) + androidTestImplementation(libs.androidx.test.core.ktx) androidTestImplementation(libs.androidx.test.ext.junit.ktx) androidTestImplementation(libs.androidx.test.rules) diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml new file mode 100644 index 00000000..0d58475d --- /dev/null +++ b/app/config/ktlint/baseline.xml @@ -0,0 +1,932 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml new file mode 100644 index 00000000..266f2311 --- /dev/null +++ b/app/detekt-baseline-debug.xml @@ -0,0 +1,235 @@ + + + + + ComplexCondition:InAppGallery.kt:InAppGallery$width != null && height != null && width > 0 && height > 0 + ComplexCondition:ZoomableImageView.kt:ZoomableImageView$oldMeasuredHeight == viewWidth && oldMeasuredHeight == viewHeight || viewWidth == 0 || viewHeight == 0 + CyclomaticComplexMethod:CamConfig.kt:CamConfig$@SuppressLint("RestrictedApi") fun startCamera + CyclomaticComplexMethod:CamConfig.kt:CamConfig$fun loadSettings + CyclomaticComplexMethod:CapturedItems.kt:CapturedItems$private fun migratePreviousUris + CyclomaticComplexMethod:InAppGallery.kt:InAppGallery$override fun onCreate + CyclomaticComplexMethod:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails + CyclomaticComplexMethod:MainActivity.kt:MainActivity$@SuppressLint("ClickableViewAccessibility") override fun onCreate + CyclomaticComplexMethod:MainActivity.kt:MainActivity$fun onDeviceAngleChange + CyclomaticComplexMethod:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$override fun onSensorChanged + CyclomaticComplexMethod:VideoCapturer.kt:VideoCapturer$fun startRecording + EmptyCatchBlock:QRAnalyzer.kt:QRAnalyzer${ } + EmptyFunctionBlock:ActivityLifeCycleHelper.kt:ActivityLifeCycleHelper${} + EmptyFunctionBlock:App.kt:App.<no name provided>${} + EmptyFunctionBlock:CamConfig.kt:CamConfig.<no name provided>${} + EmptyFunctionBlock:ImageCapturer.kt:ImageCapturer.<no name provided>${} + EmptyFunctionBlock:MainActivity.kt:MainActivity${} + EmptyFunctionBlock:MainActivity.kt:MainActivity.<no name provided>${} + EmptyFunctionBlock:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener${} + EmptyFunctionBlock:SettingsDialog.kt:SettingsDialog.<no name provided>${} + EmptyFunctionBlock:ZoomableImageView.kt:ZoomableImageView.<no name provided>${} + HasPlatformType:ImageSaver.kt:ImageSaver$val contentResolver = appContext.contentResolver + HasPlatformType:ImageSaver.kt:ImageSaver$val mainThreadExecutor = appContext.mainExecutor + HasPlatformType:ImageSaver.kt:ImageSaver.Companion$val imageCaptureCallbackExecutor = Executors.newSingleThreadExecutor() + HasPlatformType:InAppGallery.kt:InAppGallery$val asyncImageLoader = Executors.newSingleThreadExecutor() + HasPlatformType:InAppGallery.kt:InAppGallery$val asyncLoaderOfCapturedItems = Executors.newSingleThreadExecutor() + HasPlatformType:MainActivity.kt:MainActivity$val thumbnailLoaderExecutor = Executors.newSingleThreadExecutor() + HasPlatformType:SharedPrefs.kt:EphemeralSharedPrefs.Editor$val thread = Thread.currentThread() + ImplicitDefaultLocale:QRAnalyzer.kt:QRAnalyzer$"%.02f".format(fps) + ImplicitDefaultLocale:ZoomBar.kt:ZoomBar$String.format("%.1fx", zoomRatio) + InstanceOfCheckForException:CamConfig.kt:CamConfig$exception !is IllegalArgumentException + InstanceOfCheckForException:CamConfig.kt:CamConfig$exception !is UnsupportedOperationException + InstanceOfCheckForException:CamConfig.kt:CamConfig$exception is IllegalArgumentException + LargeClass:CamConfig.kt:CamConfig + LargeClass:MainActivity.kt:MainActivity : AppCompatActivityOnTouchListenerOnScaleGestureListenerOnGestureListenerOnDoubleTapListenerListener + LongMethod:BlurBitmap.kt:BlurBitmap$operator fun get: Bitmap + LongMethod:CamConfig.kt:CamConfig$@SuppressLint("RestrictedApi") fun startCamera + LongMethod:CamConfig.kt:CamConfig$fun loadSettings + LongMethod:CamConfig.kt:CamConfig$fun showMoreOptionsForQR + LongMethod:GallerySliderAdapter.kt:GallerySliderAdapter$override fun onBindViewHolder + LongMethod:InAppGallery.kt:InAppGallery$override fun onCreate + LongMethod:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails + LongMethod:MainActivity.kt:MainActivity$@SuppressLint("ClickableViewAccessibility") override fun onCreate + LongMethod:MainActivity.kt:MainActivity$fun onDeviceAngleChange + LongMethod:MainActivity.kt:MainActivity$fun onScanResultSuccess + LongMethod:MoreSettings.kt:MoreSettings$override fun onCreate + LongMethod:SettingsDialog.kt:SettingsDialog$fun selfIllumination + LongMethod:VideoCapturer.kt:VideoCapturer$fun startRecording + LongMethod:VideoPlayer.kt:VideoPlayer$override fun onCreate + MagicNumber:App.kt:App$2000 + MagicNumber:BlurBitmap.kt:BlurBitmap$0x0000ff + MagicNumber:BlurBitmap.kt:BlurBitmap$0x00ff00 + MagicNumber:BlurBitmap.kt:BlurBitmap$0xff0000 + MagicNumber:BlurBitmap.kt:BlurBitmap$16 + MagicNumber:BlurBitmap.kt:BlurBitmap$256 + MagicNumber:BlurBitmap.kt:BlurBitmap$8 + MagicNumber:CamConfig.kt:CamConfig$100 + MagicNumber:CamConfig.kt:CamConfig$95 + MagicNumber:CaptureActivity.kt:CaptureActivity$100 + MagicNumber:CaptureActivity.kt:CaptureActivity$1000000 + MagicNumber:CaptureActivity.kt:CaptureActivity$300 + MagicNumber:CountDownTimerUI.kt:CountDownTimerUI.<no name provided>$1000L + MagicNumber:CustomGrid.kt:CustomGrid$255 + MagicNumber:CustomGrid.kt:CustomGrid$3f + MagicNumber:CustomGrid.kt:CustomGrid$4f + MagicNumber:ExposureBar.kt:ExposureBar$300 + MagicNumber:ExposureBar.kt:ExposureBar$90f + MagicNumber:ImageCapturer.kt:ImageCapturer$200 + MagicNumber:InAppGallery.kt:InAppGallery$1000 + MagicNumber:InAppGallery.kt:InAppGallery$1000L + MagicNumber:InAppGallery.kt:InAppGallery$1000f + MagicNumber:InAppGallery.kt:InAppGallery$270 + MagicNumber:InAppGallery.kt:InAppGallery$300 + MagicNumber:InAppGallery.kt:InAppGallery$50 + MagicNumber:InAppGallery.kt:InAppGallery$500 + MagicNumber:InAppGallery.kt:InAppGallery$90 + MagicNumber:MainActivity.kt:MainActivity$0.05f + MagicNumber:MainActivity.kt:MainActivity$16 + MagicNumber:MainActivity.kt:MainActivity$180 + MagicNumber:MainActivity.kt:MainActivity$270 + MagicNumber:MainActivity.kt:MainActivity$270f + MagicNumber:MainActivity.kt:MainActivity$3 + MagicNumber:MainActivity.kt:MainActivity$300 + MagicNumber:MainActivity.kt:MainActivity$360f + MagicNumber:MainActivity.kt:MainActivity$4 + MagicNumber:MainActivity.kt:MainActivity$400 + MagicNumber:MainActivity.kt:MainActivity$5 + MagicNumber:MainActivity.kt:MainActivity$500 + MagicNumber:MainActivity.kt:MainActivity$7 + MagicNumber:MainActivity.kt:MainActivity$8 + MagicNumber:MainActivity.kt:MainActivity$800 + MagicNumber:MainActivity.kt:MainActivity$90 + MagicNumber:MainActivity.kt:MainActivity$90f + MagicNumber:PackageManagerUtils.kt:33 + MagicNumber:PreviewView.kt:16 + MagicNumber:PreviewView.kt:180 + MagicNumber:PreviewView.kt:3 + MagicNumber:PreviewView.kt:4 + MagicNumber:PreviewView.kt:9 + MagicNumber:QRAnalyzer.kt:QRAnalyzer$180 + MagicNumber:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$180 + MagicNumber:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$270 + MagicNumber:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$5 + MagicNumber:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$90 + MagicNumber:SettingsDialog.kt:SettingsDialog$150 + MagicNumber:SettingsDialog.kt:SettingsDialog$300 + MagicNumber:VideoCapturer.kt:VideoCapturer$1_000_000_000 + MagicNumber:VideoCapturer.kt:VideoCapturer$300 + MagicNumber:VideoPlayer.kt:VideoPlayer$300 + MagicNumber:ZoomBar.kt:ZoomBar$100 + MagicNumber:ZoomBar.kt:ZoomBar$100f + MagicNumber:ZoomBar.kt:ZoomBar$300 + MagicNumber:ZoomBar.kt:ZoomBar$90f + MatchingDeclarationName:ImageDecoderUtils.kt:ImageResizer : OnHeaderDecodedListener + MaxLineLength:CamConfig.kt:CamConfig$resolutionSelectorBuilder.setAllowedResolutionMode(ResolutionSelector.PREFER_HIGHER_RESOLUTION_OVER_CAPTURE_RATE) + MaxLineLength:CapturedItems.kt:CapturedItems$private + MaxLineLength:CapturedItems.kt:CapturedItems$val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME) + MaxLineLength:MainActivity.kt:MainActivity$if (cameraPermissionDialog != null && cameraPermissionDialog!!.isShowing) cameraPermissionDialog!!.cancel() + MaxLineLength:SettingsDialog.kt:SettingsDialog$mActivity.showMessage("Enabling audio while recording is not currently supported when it was disabled at the start") + MaxLineLength:SharedPrefs.kt:EphemeralSharedPrefs$override + MaxLineLength:SharedPrefs.kt:fun + NestedBlockDepth:CapturedItems.kt:CapturedItems$private fun migratePreviousUris + NestedBlockDepth:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails + NewLineAtEndOfFile:GSlideTransformer.kt:app.grapheneos.camera.GSlideTransformer.kt + NewLineAtEndOfFile:SettingsFrameLayout.kt:app.grapheneos.camera.ui.SettingsFrameLayout.kt + NewLineAtEndOfFile:SystemSettingsObserver.kt:app.grapheneos.camera.ktx.SystemSettingsObserver.kt + NewLineAtEndOfFile:VideoCaptureActivity.kt:app.grapheneos.camera.ui.activities.VideoCaptureActivity.kt + NewLineAtEndOfFile:VideoOnlyActivity.kt:app.grapheneos.camera.ui.activities.VideoOnlyActivity.kt + NoNameShadowing:CapturedItems.kt:CapturedItems${ Uri.parse(it) } + NoNameShadowing:CapturedItems.kt:CapturedItems${ dest.add(it) } + NoNameShadowing:MoreSettings.kt:MoreSettings${ if (it.toString().contains(CapturedItems.SAF_TREE_SEPARATOR)) { null } else { it } } + NoNameShadowing:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$xAngle + NoNameShadowing:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$zAngle + PrintStackTrace:InAppGallery.kt:InAppGallery$e + PrintStackTrace:MainActivity.kt:MainActivity$exception + PrintStackTrace:VideoCapturer.kt:VideoCapturer$e + PrintStackTrace:VideoCapturer.kt:e + ReturnCount:App.kt:App$fun getLocation: Location? + ReturnCount:App.kt:App$fun isAnyLocationProvideActive: Boolean + ReturnCount:BottomTabLayout.kt:BottomTabLayout$override fun onScrollChanged + ReturnCount:CamConfig.kt:CamConfig$@SuppressLint("RestrictedApi") fun startCamera + ReturnCount:CamConfig.kt:CamConfig$@androidx.annotation.OptIn(ExperimentalCamera2Interop::class) private fun canVerifyFeatureCombinations: Boolean + ReturnCount:CamConfig.kt:CamConfig$private fun isExtensionUsable: Boolean + ReturnCount:CamConfig.kt:CamConfig$private fun isLensFacingSupported : Boolean + ReturnCount:CamConfig.kt:CamConfig$private fun loadTabs + ReturnCount:CamConfig.kt:CamConfig$private fun videoQualityAsGroupableFeature: GroupableFeature? + ReturnCount:CapturedItems.kt:CapturedItems$fun parseCapturedItem: CapturedItem? + ReturnCount:ImageCapturer.kt:ImageCapturer$@SuppressLint("RestrictedApi") fun takePicture + ReturnCount:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails + ReturnCount:MainActivity.kt:MainActivity$override fun onTouch: Boolean + ReturnCount:MainActivity.kt:MainActivity$private fun onSwipeLeft + ReturnCount:MainActivity.kt:MainActivity$private fun onSwipeRight + ReturnCount:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$override fun onSensorChanged + ReturnCount:SettingsDialog.kt:SettingsDialog$private fun updatePanelRegion: Boolean + ReturnCount:Utils.kt:fun storageLocationToUiString: String + ReturnCount:VideoCapturer.kt:VideoCapturer$fun startRecording + ReturnCount:VideoCapturer.kt:VideoCapturer$private fun createRecordingContext: RecordingContext? + SwallowedException:CamConfig.kt:CamConfig$e : IllegalArgumentException + SwallowedException:CamConfig.kt:CamConfig$e: ExecutionException + SwallowedException:CaptureActivity.kt:CaptureActivity$e: Exception + SwallowedException:CapturedItems.kt:e: ActivityNotFoundException + SwallowedException:GallerySliderAdapter.kt:GallerySliderAdapter$e: Exception + SwallowedException:QRAnalyzer.kt:QRAnalyzer$e: ReaderException + SwallowedException:SettingsDialog.kt:SettingsDialog$exception: Exception + SwallowedException:SettingsDialog.kt:SettingsDialog.<no name provided>$exception: Exception + SwallowedException:VideoCapturer.kt:VideoCapturer$e: Exception + SwallowedException:VideoCapturer.kt:VideoCapturer$exception: Exception + ThrowsCount:ImageSaver.kt:ImageSaver$@Throws(ImageSaverException::class) private fun saveImageInner + TooGenericExceptionCaught:CamConfig.kt:CamConfig$e: Exception + TooGenericExceptionCaught:CamConfig.kt:CamConfig$exception: RuntimeException + TooGenericExceptionCaught:CaptureActivity.kt:CaptureActivity$e: Exception + TooGenericExceptionCaught:CapturedItems.kt:CapturedItems$e: Exception + TooGenericExceptionCaught:GallerySliderAdapter.kt:GallerySliderAdapter$e: Exception + TooGenericExceptionCaught:ImageSaver.kt:ImageSaver$deleteException: Exception + TooGenericExceptionCaught:ImageSaver.kt:ImageSaver$e: Exception + TooGenericExceptionCaught:InAppGallery.kt:InAppGallery$e: Exception + TooGenericExceptionCaught:MainActivity.kt:MainActivity$e: Exception + TooGenericExceptionCaught:MainActivity.kt:MainActivity$exception: Exception + TooGenericExceptionCaught:SettingsDialog.kt:SettingsDialog$exception: Exception + TooGenericExceptionCaught:SettingsDialog.kt:SettingsDialog.<no name provided>$exception: Exception + TooGenericExceptionCaught:VideoCapturer.kt:VideoCapturer$e: Exception + TooGenericExceptionCaught:VideoCapturer.kt:VideoCapturer$exception: Exception + TooGenericExceptionCaught:VideoCapturer.kt:e: Exception + TooGenericExceptionCaught:VideoPlayer.kt:VideoPlayer$e: Exception + TooManyFunctions:CapturedItems.kt:CapturedItems + TooManyFunctions:MainActivity.kt:MainActivity : AppCompatActivityOnTouchListenerOnScaleGestureListenerOnGestureListenerOnDoubleTapListenerListener + TopLevelPropertyNaming:ImageCapturer.kt:private const val imageFileFormat = ".jpg" + UnsafeCallOnNullableType:BlurBitmap.kt:BlurBitmap$sentBitmap.config!! + UnsafeCallOnNullableType:BottomTabLayout.kt:BottomTabLayout$getTabAt(it)!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$camera!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$cameraProvider!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$commonPref.getString( SettingValues.Key.STORAGE_LOCATION, SettingValues.Default.STORAGE_LOCATION )!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$imageCapture!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$modePref.getString(videoQualityKey, "")!! + UnsafeCallOnNullableType:CamConfig.kt:CamConfig$videoCapture!! + UnsafeCallOnNullableType:CapturedItems.kt:CapturedItem.Companion.<no name provided>$source.readString()!! + UnsafeCallOnNullableType:CapturedItems.kt:CapturedItems$uri.authority!! + UnsafeCallOnNullableType:ImageCapturer.kt:ImageCapturer$camConfig.imageCapture!! + UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$DocumentsContract.createDocument(contentResolver, treeDocumentUri, mimeType(), fileName())!! + UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$contentResolver.openAssetFileDescriptor(uri, "w")!! + UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$obtainOutputUri()!! + UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$origJpegBytes!! + UnsafeCallOnNullableType:InAppGallery.kt:InAppGallery$eInterface.getAttribute(ExifInterface.TAG_DATETIME)!! + UnsafeCallOnNullableType:InAppGallery.kt:InAppGallery$eInterface.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)!! + UnsafeCallOnNullableType:MainActivity.kt:MainActivity$camConfig.camera!! + UnsafeCallOnNullableType:MainActivity.kt:MainActivity$cameraPermissionDialog!! + UnsafeCallOnNullableType:MainActivity.kt:MainActivity$data.encodedPath!! + UnsafeCallOnNullableType:MainActivity.kt:MainActivity$data?.encodedPath!! + UnsafeCallOnNullableType:QrTile.kt:QrTile$getSystemService<KeyguardManager>()!! + UnsafeCallOnNullableType:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier$wr.get()!! + UnsafeCallOnNullableType:SettingsDialog.kt:SettingsDialog$Looper.myLooper()!! + UnsafeCallOnNullableType:SettingsDialog.kt:SettingsDialog.<no name provided>$ev!! + UnsafeCallOnNullableType:SharedPrefs.kt:EphemeralSharedPrefs$key!! + UnsafeCallOnNullableType:SharedPrefs.kt:EphemeralSharedPrefs.Editor$key!! + UnsafeCallOnNullableType:VideoCapturer.kt:VideoCapturer$createRecordingContext(recorder, fileName)!! + UnsafeCallOnNullableType:VideoPlayer.kt:VideoPlayer$getParcelableExtra<Uri>(intent, VIDEO_URI)!! + UnsafeCallOnNullableType:ZoomableImageView.kt:ZoomableImageView$currentInstance.mScaleDetector!! + UnusedPrivateProperty:AutoFinishOnSleep.kt:AutoFinishOnSleep.Companion$private const val TAG = "AutoFinishOnSleep" + UnusedUnaryOperator:BlurBitmap.kt:BlurBitmap$-0x1000000 + UseCheckOrError:ImageSaver.kt:ImageSaver$throw IllegalStateException("unknown imageFormat $imageFormat") + VarCouldBeVal:ZoomBar.kt:ZoomBar$@SuppressLint("InflateParams") private var thumbView: View = LayoutInflater.from(context) .inflate(R.layout.zoom_bar_thumb, null, false) + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var last = PointF() + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var m: FloatArray = FloatArray(9) + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var maxScale = 3f + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var minScale = 1f + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var singleClickHandler = Handler(Looper.getMainLooper()) + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var singleClickRunnable = Runnable { onSingleClick() } + VarCouldBeVal:ZoomableImageView.kt:ZoomableImageView$private var start = PointF() + VariableNaming:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$private val ALPHA = 0.7f + + diff --git a/app/detekt-baseline-debugAndroidTest.xml b/app/detekt-baseline-debugAndroidTest.xml new file mode 100644 index 00000000..9686b59c --- /dev/null +++ b/app/detekt-baseline-debugAndroidTest.xml @@ -0,0 +1,11 @@ + + + + + AbstractClassCanBeConcreteClass:EditMediaRegressionTest.kt:EditMediaRegressionTest.HostActivity$HostActivity + AbstractClassCanBeConcreteClass:ShareMediaRegressionTest.kt:ShareMediaRegressionTest.HostActivity$HostActivity + EmptyFunctionBlock:InAppGalleryRegressionTest.kt:InAppGalleryRegressionTest.StalledMediaScan${} + PrintStackTrace:VideoCapturerRegressionTest.kt:VideoCapturerRegressionTest$e + UseCheckOrError:VideoPlayerRegressionTest.kt:VideoPlayerRegressionTest.DeadMediaServiceVideoView$throw IllegalStateException("prepareAsync called in state 0") + + diff --git a/app/src/androidTest/java/app/grapheneos/camera/EntryPointContractTest.kt b/app/src/androidTest/java/app/grapheneos/camera/EntryPointContractTest.kt new file mode 100644 index 00000000..42e2b5c7 --- /dev/null +++ b/app/src/androidTest/java/app/grapheneos/camera/EntryPointContractTest.kt @@ -0,0 +1,175 @@ +package app.grapheneos.camera + +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EntryPointContractTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val packageManager: PackageManager = context.packageManager + + private fun activityInfoFor(action: String): ActivityInfo { + val intent = Intent(action).setPackage(context.packageName) + val matches = packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL) + + assertEquals( + "Exactly one component in this app must answer $action, but got" + + " ${matches.map { it.activityInfo.name }}", + 1, + matches.size, + ) + return matches.single().activityInfo + } + + private fun assertHandledBy( + action: String, + expectedComponent: String, + ) { + assertEquals( + "$action must be handled by $expectedComponent", + "$PACKAGE.$expectedComponent", + activityInfoFor(action).name, + ) + } + + private fun assertIsLockscreenEntryPoint( + action: String, + expectedAffinity: String, + ) { + val info = activityInfoFor(action) + + assertTrue( + "${info.name} must show over the keyguard, or $action does nothing on a locked" + + " phone", + info.flags and FLAG_SHOW_WHEN_LOCKED != 0, + ) + assertTrue( + "${info.name} must be excluded from recents, or what a locked session captured is" + + " listed to whoever picks the phone up next", + info.flags and ActivityInfo.FLAG_EXCLUDE_FROM_RECENTS != 0, + ) + + assertTrue( + "${info.name} must keep its own taskAffinity ending in" + + " .ui.activities.$expectedAffinity, or the locked session can surface the" + + " unlocked task, but it is ${info.taskAffinity}", + info.taskAffinity.orEmpty().endsWith(".ui.activities.$expectedAffinity"), + ) + } + + @Test + fun stillImageCameraIsAnAliasOntoTheMainActivity() { + val info = activityInfoFor("android.media.action.STILL_IMAGE_CAMERA") + + assertEquals("$PACKAGE.ui.activities.CameraLauncher", info.name) + assertEquals("$PACKAGE.ui.activities.MainActivity", info.targetActivity) + } + + @Test + fun videoCameraLaunchesTheVideoOnlyActivity() { + assertHandledBy("android.media.action.VIDEO_CAMERA", "ui.activities.VideoOnlyActivity") + } + + @Test + fun imageCaptureLaunchesTheCaptureActivity() { + assertHandledBy("android.media.action.IMAGE_CAPTURE", "ui.activities.CaptureActivity") + } + + @Test + fun videoCaptureLaunchesTheVideoCaptureActivity() { + assertHandledBy( + "android.media.action.VIDEO_CAPTURE", + "ui.activities.VideoCaptureActivity", + ) + } + + @Test + fun secureStillImageCameraLaunchesTheSecureMainActivity() { + assertHandledBy( + "android.media.action.STILL_IMAGE_CAMERA_SECURE", + "ui.activities.SecureMainActivity", + ) + } + + @Test + fun secureImageCaptureLaunchesTheSecureCaptureActivity() { + assertHandledBy( + "android.media.action.IMAGE_CAPTURE_SECURE", + "ui.activities.SecureCaptureActivity", + ) + } + + @Test + fun secureStillImageCameraIsALockscreenEntryPoint() { + assertIsLockscreenEntryPoint( + action = "android.media.action.STILL_IMAGE_CAMERA_SECURE", + expectedAffinity = "SecureMainActivity", + ) + } + + @Test + fun secureImageCaptureIsALockscreenEntryPoint() { + assertIsLockscreenEntryPoint( + action = "android.media.action.IMAGE_CAPTURE_SECURE", + expectedAffinity = "SecureCaptureActivity", + ) + } + + @Test + fun theUnlockedEntryPointsDoNotShowOverTheKeyguard() { + // The mirror of the assertions above: were every activity showWhenLocked, they would + // pass while the distinction they exist to protect had been erased. + listOf( + "android.media.action.VIDEO_CAMERA", + "android.media.action.IMAGE_CAPTURE", + "android.media.action.VIDEO_CAPTURE", + ).forEach { action -> + val info = activityInfoFor(action) + + assertEquals( + "${info.name} answers the non-secure $action and must not show over the" + + " keyguard", + 0, + info.flags and FLAG_SHOW_WHEN_LOCKED, + ) + } + } + + @Test + fun qrTileKeepsTheNameAndFlagsSystemUiDependsOn() { + val info = packageManager.getActivityInfo( + ComponentName(context.packageName, "$PACKAGE.ui.activities.QrTile"), + PackageManager.MATCH_ALL, + ) + + assertTrue( + "QrTile must stay exported — SystemUI starts it from outside the app", + info.exported, + ) + assertTrue( + "QrTile must show over the keyguard; it is a lockscreen shortcut target", + info.flags and FLAG_SHOW_WHEN_LOCKED != 0, + ) + assertTrue( + "QrTile must be excluded from recents", + info.flags and ActivityInfo.FLAG_EXCLUDE_FROM_RECENTS != 0, + ) + assertNull("QrTile is a real activity, not an alias", info.targetActivity) + } + + private companion object { + const val PACKAGE = "app.grapheneos.camera" + + // ActivityInfo.FLAG_SHOW_WHEN_LOCKED is @hide + const val FLAG_SHOW_WHEN_LOCKED = 0x800000 + } +} diff --git a/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt new file mode 100644 index 00000000..0529946e --- /dev/null +++ b/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt @@ -0,0 +1,131 @@ +package app.grapheneos.camera + +import android.Manifest +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import app.grapheneos.camera.ui.activities.MainActivity +import app.grapheneos.camera.ui.activities.SecureMainActivity +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * A lockscreen session may read the owner's settings but must never write them: whoever picks + * up a locked phone would otherwise be able to change what the owner sees after unlocking. + * SecureMainActivity enforces this by overriding getSharedPreferences() to return an ephemeral + * clone, and CamConfig obtains its preferences through the activity — rather than through the + * application context — precisely so it inherits that. + * + * A settings repository injected with the application context would satisfy every other test + * in this suite and silently undo it. + */ +@RunWith(AndroidJUnit4::class) +class SecurePrefsIsolationTest { + @get:Rule + val grantPermissions: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.CAMERA, + ) + + /** Both activities bind a camera, which a dozing or locked device cannot provide. */ + @get:Rule + val screenAwake = ScreenAwakeRule() + + private val context: Context = InstrumentationRegistry + .getInstrumentation() + .targetContext + .applicationContext + + private fun persistentPrefs(): SharedPreferences { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + @After + fun removeProbeKey() { + persistentPrefs().edit().remove(PROBE_KEY).commit() + } + + @Test + fun theSecureActivityDoesNotHandOutThePersistentPrefs() { + ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + assertNotSame( + "SecureMainActivity handed out the persistent preferences — a locked" + + " session can now overwrite the owner's settings", + persistentPrefs(), + activity.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), + ) + } + } + } + + @Test + fun writesInASecureSessionDoNotChangeThePersistentPrefs() { + persistentPrefs().edit().putInt(PROBE_KEY, 1).commit() + + ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putInt(PROBE_KEY, 2) + .commit() + } + } + + assertEquals( + "A secure session wrote through to the persistent preferences", + 1, + persistentPrefs().getInt(PROBE_KEY, -1), + ) + } + + @Test + fun aSecureSessionStillReadsTheOwnersSettings() { + persistentPrefs().edit().putInt(PROBE_KEY, 3).commit() + + ActivityScenario.launch(SecureMainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + assertEquals( + "The isolation must be one-way: a lockscreen session still honours the" + + " settings the owner chose", + 3, + activity + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getInt(PROBE_KEY, -1), + ) + } + } + } + + @Test + fun theRegularActivityDoesWriteThePersistentPrefs() { + // The mirror of the tests above: if this ever fails, they would pass for the wrong + // reason — because nothing writes preferences at all. + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putInt(PROBE_KEY, 7) + .commit() + } + } + + assertEquals(7, persistentPrefs().getInt(PROBE_KEY, -1)) + } + + private companion object { + // CamConfig.COMMON_SHARED_PREFS_NAME + const val PREFS_NAME = "commons" + + /** Not a real setting, so a failed run cannot corrupt the app's configuration. */ + const val PROBE_KEY = "securePrefsIsolationProbe" + } +} diff --git a/app/src/test/java/app/grapheneos/camera/util/EphemeralSharedPrefsTest.kt b/app/src/test/java/app/grapheneos/camera/util/EphemeralSharedPrefsTest.kt new file mode 100644 index 00000000..a3c4281f --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/util/EphemeralSharedPrefsTest.kt @@ -0,0 +1,122 @@ +package app.grapheneos.camera.util + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * [EphemeralSharedPrefs] is what stops a lockscreen session from changing the settings the + * owner sees after unlocking: SecureMainActivity and SecureCaptureActivity override + * getSharedPreferences() to hand out one of these, cloned from the real preferences but + * backed by memory, and CamConfig deliberately reads its preferences through the activity so + * it inherits that. + * + * The clone being one-way is the entire security property, and nothing asserted it. + */ +@RunWith(RobolectricTestRunner::class) +class EphemeralSharedPrefsTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + private fun persistentPrefs(): SharedPreferences { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + private fun ephemeralPrefs(cloneOriginal: Boolean = true): SharedPreferences { + return EphemeralSharedPrefsNamespace() + .getPrefs(context, PREFS_NAME, Context.MODE_PRIVATE, cloneOriginal = cloneOriginal) + } + + @Before + fun resetPersistentPrefs() { + persistentPrefs().edit().clear().commit() + } + + @Test + fun clonesExistingValuesFromThePersistentPrefs() { + persistentPrefs().edit().putInt("photoQuality", 85).commit() + + assertEquals(85, ephemeralPrefs().getInt("photoQuality", -1)) + } + + @Test + fun writesNeverReachThePersistentPrefs() { + persistentPrefs().edit().putInt("photoQuality", 85).commit() + + val ephemeral = ephemeralPrefs() + ephemeral.edit().putInt("photoQuality", 20).commit() + + assertEquals(20, ephemeral.getInt("photoQuality", -1)) + assertEquals(85, persistentPrefs().getInt("photoQuality", -1)) + } + + @Test + fun removalsNeverReachThePersistentPrefs() { + persistentPrefs().edit().putBoolean("includeAudio", true).commit() + + val ephemeral = ephemeralPrefs() + ephemeral.edit().remove("includeAudio").commit() + + assertFalse(ephemeral.contains("includeAudio")) + assertTrue(persistentPrefs().contains("includeAudio")) + } + + @Test + fun clearNeverReachesThePersistentPrefs() { + persistentPrefs().edit().putBoolean("includeAudio", true).commit() + + val ephemeral = ephemeralPrefs() + ephemeral.edit().clear().commit() + + assertFalse(ephemeral.contains("includeAudio")) + assertTrue(persistentPrefs().contains("includeAudio")) + } + + @Test + fun aRepeatedLookupKeepsTheSessionsChanges() { + persistentPrefs().edit().putInt("photoQuality", 85).commit() + val namespace = EphemeralSharedPrefsNamespace() + + val first = namespace + .getPrefs(context, PREFS_NAME, Context.MODE_PRIVATE, cloneOriginal = true) + first.edit().putInt("photoQuality", 42).commit() + val second = namespace + .getPrefs(context, PREFS_NAME, Context.MODE_PRIVATE, cloneOriginal = true) + + // A second lookup that re-cloned from disk would silently discard everything the + // session changed and hand back the persistent value instead. + assertEquals(42, second.getInt("photoQuality", -1)) + } + + @Test + fun startsEmptyWhenNotCloning() { + persistentPrefs().edit().putInt("photoQuality", 85).commit() + + assertFalse(ephemeralPrefs(cloneOriginal = false).contains("photoQuality")) + } + + @Test + fun rejectsAnyModeOtherThanPrivate() { + val failure = runCatching { + EphemeralSharedPrefsNamespace() + .getPrefs(context, PREFS_NAME, Context.MODE_APPEND, cloneOriginal = true) + }.exceptionOrNull() + + assertTrue( + "Only MODE_PRIVATE is supported, and anything else must fail loudly rather than" + + " return preferences with the wrong semantics, but got $failure", + failure is IllegalArgumentException, + ) + } + + private companion object { + // CamConfig.COMMON_SHARED_PREFS_NAME + const val PREFS_NAME = "commons" + } +} diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 00000000..3f67ea5a --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=35 diff --git a/build.gradle.kts b/build.gradle.kts index 3d33ced3..071888d3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,8 @@ +import org.jlleitschuh.gradle.ktlint.KtlintExtension + plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.ktlint) } buildscript { @@ -9,8 +12,42 @@ buildscript { } } +val ktlintCliVersion: String = the() + .named("libs") + .findVersion("ktlint") + .get() + .requiredVersion + +configure { + version.set(ktlintCliVersion) + + filter { + exclude("**/build/**") + } +} + +subprojects { + apply(plugin = "org.jlleitschuh.gradle.ktlint") + + configure { + version.set(ktlintCliVersion) + + filter { + exclude("**/build/**") + } + } +} + allprojects { tasks.withType { - options.compilerArgs.addAll(listOf("-Xlint", "-Xlint:-cast", "-Xlint:-classfile", "-Xlint:-rawtypes", "-Xlint:-serial")) + options.compilerArgs.addAll( + listOf( + "-Xlint", + "-Xlint:-cast", + "-Xlint:-classfile", + "-Xlint:-rawtypes", + "-Xlint:-serial", + ), + ) } } diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 00000000..2656da8b --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,52 @@ +config: + validation: true + warningsAsErrors: true + +complexity: + LongParameterList: + active: false + ignoreDefaultParameters: true + ignoreAnnotated: + - Composable + TooManyFunctions: + allowedFunctionsPerClass: 60 + allowedFunctionsPerFile: 15 + allowedFunctionsPerInterface: 50 + ignoreAnnotatedFunctions: + - Preview + - PreviewLightDark + LongMethod: + ignoreAnnotated: + - Preview + - PreviewLightDark + +coroutines: + InjectDispatcher: + # Stays active — AGENTS.md requires dispatchers to arrive through a qualifier. A Hilt + # module is the one place that names a dispatcher, which is what the exemption covers. + ignoreAnnotated: + - Provides + +naming: + FunctionNaming: + ignoreAnnotated: + - Composable + +style: + AbstractClassCanBeInterface: + ignoreAnnotated: + - Module + + ForbiddenComment: + active: false + + MagicNumber: + ignoreCompanionObjectPropertyDeclaration: true + ignorePropertyDeclaration: true + ignoreAnnotated: + - Composable + + UnusedPrivateFunction: + ignoreAnnotated: + - Preview + - PreviewLightDark diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bd3ba351..2ab11d27 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,8 +3,12 @@ agp = "9.3.1" kotlin = "2.4.10" ksp = "2.3.10" +detekt = "2.0.0-alpha.5" +ktlint = "1.8.0" +ktlint-gradle = "14.2.0" + appcompat = "1.7.1" -camerax = "1.6.1" +camerax = { strictly = "1.6.1" } constraintlayout = "2.2.2" coreKtx = "1.19.0" material = "1.14.0" @@ -14,6 +18,8 @@ androidxTestCore = "1.7.0" androidxTestExtJunit = "1.3.0" androidxTestRules = "1.7.0" androidxTestRunner = "1.7.0" +junit4 = "4.13.2" +robolectric = "4.16.1" [libraries] androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -33,6 +39,8 @@ androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "and androidx-test-ext-junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "androidxTestExtJunit" } androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTestRules" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } +junit4 = { module = "junit:junit", version.ref = "junit4" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } ksp-gradle-plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } @@ -49,3 +57,5 @@ camerax = [ [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +detekt = { id = "dev.detekt", version.ref = "detekt" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 3bc86b39..dc7f8807 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1735,6 +1735,17 @@ + + + + + + + + + + + @@ -1926,6 +1937,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2762,6 +2820,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2877,6 +3027,20 @@ + + + + + + + + + + + + + + @@ -2905,6 +3069,11 @@ + + + + + @@ -3187,6 +3356,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3309,6 +3506,11 @@ + + + + + @@ -3359,6 +3561,11 @@ + + + + + @@ -3401,6 +3608,20 @@ + + + + + + + + + + + + + + @@ -3510,6 +3731,23 @@ + + + + + + + + + + + + + + + + + @@ -3550,11 +3788,21 @@ + + + + + + + + + + @@ -3923,6 +4171,25 @@ + + + + + + + + + + + + + + + + + + + @@ -3956,6 +4223,275 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4111,86 +4647,676 @@ - - - + + + - - - - + + - - + + - - + + - - + + + + + - - - + + + - - + + - - + + - - + + + + + - - - + + + - - + + - - + + + + + + + + - - - + + + - - + + - - + + - - + + + + + - - - + + + - - + + - - + + - - + + + + + - - - + + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5289,6 +6415,20 @@ + + + + + + + + + + + + + + @@ -5314,6 +6454,23 @@ + + + + + + + + + + + + + + + + + @@ -5410,6 +6567,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5477,6 +6667,23 @@ + + + + + + + + + + + + + + + + + @@ -5519,6 +6726,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5801,6 +7041,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5896,6 +7164,20 @@ + + + + + + + + + + + + + + @@ -6443,6 +7725,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6526,6 +7836,17 @@ + + + + + + + + + + + @@ -6615,6 +7936,23 @@ + + + + + + + + + + + + + + + + + @@ -6632,6 +7970,20 @@ + + + + + + + + + + + + + + @@ -6683,6 +8035,23 @@ + + + + + + + + + + + + + + + + + @@ -6703,6 +8072,23 @@ + + + + + + + + + + + + + + + + + @@ -6713,6 +8099,9 @@ + + + @@ -6833,6 +8222,17 @@ + + + + + + + + + + + @@ -6858,6 +8258,17 @@ + + + + + + + + + + + @@ -6888,6 +8299,22 @@ + + + + + + + + + + + + + + + + @@ -6930,6 +8357,20 @@ + + + + + + + + + + + + + + @@ -6952,6 +8393,17 @@ + + + + + + + + + + + @@ -6980,6 +8432,17 @@ + + + + + + + + + + + @@ -6994,6 +8457,20 @@ + + + + + + + + + + + + + + @@ -7421,6 +8898,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7497,6 +9130,20 @@ + + + + + + + + + + + + + + @@ -7536,6 +9183,20 @@ + + + + + + + + + + + + + + @@ -7550,6 +9211,20 @@ + + + + + + + + + + + + + + @@ -7589,6 +9264,212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7603,11 +9484,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7632,5 +9551,19 @@ + + + + + + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 21093c15..2aac19dd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,10 @@ +@file:Suppress("UnstableApiUsage") + pluginManagement { repositories { google() mavenCentral() + gradlePluginPortal() } } dependencyResolutionManagement {