Compare commits

..
3 Commits
Author SHA1 Message Date
darken 7a48171541 Release: 2.17.1-rc0 2025-06-12 08:58:06 +02:00
darken 9b6fd7aa57 Update translations 2025-06-11 21:54:11 +02:00
darken 5d7062171d Refine default changelog text 2025-06-11 21:50:49 +02:00
1857 changed files with 21969 additions and 141922 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

-3
View File
@@ -1,3 +0,0 @@
worktrees/
tmp/
scheduled_tasks.lock
-72
View File
@@ -1,72 +0,0 @@
# CAPod - Companion for AirPods
Android app that detects and monitors AirPods via Bluetooth LE. Displays battery levels, triggers popup notifications on case open, and provides home screen widgets.
## Project Structure
Single Gradle module `app/` with multiple source sets (`main`, `foss`, `gplay`, `debug`, `test`, `testFoss`, `testGplay`, `screenshotTest`). A previous `app-common/` module was merged into `app/`.
## Build Flavors
- **FOSS** (`foss`): Open-source, no Google Play dependencies
- **Google Play** (`gplay`): Includes billing client for IAP
Quick build check: `./gradlew assembleFossDebug`
## Key Locations
| Path | Contains |
|------|----------|
| `app/src/main/java/` | Main app source (Compose screens, services, receivers, monitor, bluetooth, models) |
| `app/src/foss/java/`, `app/src/gplay/java/` | Flavor-specific code (e.g. upgrade/billing) |
| `app/src/main/res/` | Layouts, drawables, strings |
| `app/src/test/`, `app/src/testFoss/`, `app/src/testGplay/` | Unit tests (shared + flavor-specific) |
| `app/build.gradle.kts` | App build config, dependencies, flavors |
| `app/src/debug/java/.../screenshots/` | Play Store screenshot content composables |
| `fastlane/` | Screenshot generation scripts, Play Store metadata |
## Development Tips
- Use `assembleFossDebug` as the fastest build variant for iteration
- Follow existing patterns — the codebase uses MVVM + Hilt + Coroutines
- Always use string resources for user-facing text
- Check `git log --oneline -20` for commit message style before committing
- Ordinary unit tests use JUnit 5 + kotest assertions + mockk and extend `testhelpers.BaseTest` — not
the Android defaults. `testFossDebugUnitTest` does not run `testGplay` tests
- Changing a production screen that backs a `@PreviewTest` entry in `PlayStoreScreenshots.kt` means
regenerating the smoke screenshot set
## Rules Reference
Always loaded:
| Rule | Covers |
|------|--------|
| `.claude/rules/architecture.md` | BLE vs AAP paths, `DeviceMonitor` merge boundary, FOSS pro gating |
| `.claude/rules/build-commands.md` | Gradle commands and what CI actually gates |
| `.claude/rules/commit-guidelines.md` | Commit message format and prefixes |
| `.claude/rules/pull-requests.md` | PR title and description conventions |
| `.claude/rules/agent-instructions.md` | Delegation limits and implementation scope |
| `.claude/rules/release.md` | Release guardrails — never hand-edit versions or tags |
Loaded on demand, when a matching file is read (`paths:` frontmatter):
| Rule | Loads for |
|------|-----------|
| `.claude/rules/architecture-aap-protocol.md` | `**/aap/**`, conversation reaction |
| `.claude/rules/code-style.md` | Kotlin/Compose sources in `main/`, `foss/`, `gplay/`, `debug/` |
| `.claude/rules/testing.md` | `app/src/test/`, `testFoss/`, `testGplay/` |
| `.claude/rules/localization.md` | `**/res/values/strings.xml` (base locale) |
| `.claude/rules/screenshots.md` | Screenshot composables, `screenshotTest/`, fastlane scripts |
Skills, invoked by name:
| Skill | Purpose |
|-------|---------|
| `/release` | Release workflow dispatch, inputs, channel mapping, rollback |
## Scratch space
`.claude/tmp/` is gitignored. Put plans, repro screenshots, throwaway scripts, and captured logs
there rather than in `/tmp` — they stay greppable and survive across sessions without being
committed.
-32
View File
@@ -1,32 +0,0 @@
---
description: Sub-agent delegation limits and implementation scope for this project
---
# Agent Instructions
## Delegation
Delegation adds coordination overhead and multiplies token cost, so it has to earn its place through
genuine independence and parallel speedup.
- Delegate only for large, genuinely independent work that parallelizes — a wide multi-file
investigation across unrelated areas, for example
- Don't delegate what you'd finish yourself in a handful of tool calls
- Don't spawn a sub-agent to verify or double-check your own work
- If one sub-agent can do it, use one rather than several
- Sub-agents don't inherit your conversation — state the full task, the relevant paths, and
whether you want research only or research plus implementation
- `Explore` is the right type for read-only codebase investigation
Running Gradle through the build-runner agent is a separate standing rule in the user's global
CLAUDE.md; it is context isolation, not delegation, and this file does not restate it.
## Implementation scope
- Follow existing patterns — match the code style and architecture already in use
- Change only what the task needs
- When behavior is unexpected, fix the root cause rather than working around it
- Don't create new files when editing an existing one would do
- Don't refactor surrounding code while fixing a bug
- Don't add comments or docs to code you didn't change
- Don't guess at file paths — use Glob/Grep
@@ -1,67 +0,0 @@
---
description: AAP protocol landmines and settled dead ends — what not to send, and what has already been proven impossible
paths:
- "app/src/main/java/**/aap/**"
- "app/src/main/java/**/monitor/core/aap/**"
- "app/src/main/java/**/reaction/core/conversation/**"
---
# AAP Protocol — Landmines and Dead Ends
This file holds only what the code doesn't already say. The Conversational Awareness status
taxonomy is KDoc'd on `ConversationAwarenessEvent`, the `0x4B` frame shapes are documented at the
decode site in `DefaultAapDeviceProfile`, and known control IDs are catalogued in `AapControlId`
read those, don't duplicate them here.
## Never send `0x0001` mid-session
`AapMessageType.CAPABILITIES_REQUEST` (`0x0001`) is a **handshake-phase opcode only**. Sending
`04 00 04 00 01 00` on an established session makes the device close the L2CAP stream
(`Stream closed by remote`), forcing a full reconnect. Verified experimentally on AirPods Pro 3
(fw `81.2675000075000000.6503`). The Stream State Info payload also differs between the original
connection (45B) and the forced reconnect (28B), suggesting device-side state loss.
The enum lists it with no warning, so it looks callable. It isn't.
This came up as a "cheap refresh settings" probe after writing `DYNAMIC_END_OF_CHARGE` (`0x3B`),
because the device doesn't echo that write in-session. **There is no read-setting primitive in AAP.**
Settings are push-only — on connect, on external change, or not at all. The supported pattern is
optimistic UI state + profile-learned persistence + let reconnects refresh.
## `0x37` does not use the Apple-bool encoding
Hearing Protection PPE (`0x37`, the EN 352 82 dBA media cap) is **Pro 3 only** and encodes as a
plain `01 = on` / `00 = off`. Every other AAP boolean uses the Apple-bool convention where false is
`0x02``encodeAppleBool` is wrong for this one. Companion `0x38` carries the cap level
(observed `0x52` = 82 dBA).
Hardware-confirmed reads on Pro 3 (A3064), 2026-06-10. A full settings flood on Pro 2 USB-C (A3048)
never contains `0x37` or `0x38`. Write-effect is **not** yet hardware-verified; it goes over the same
PSM `0x1001` channel CAPod already writes ANC and CA to, so the risk is low, but it is untested.
## Settled dead ends — do not re-investigate
**Real-time ambient dB level (#521) is not implementable.** AirPods send no dB or attenuation
telemetry over any AAP opcode or ATT characteristic. Apple's feature measures SPL with the Watch or
iPhone microphone and subtracts a *static per-model lookup table* held in the private
`HearingUtilities.framework`; AirPods contribute only their current listening mode. Established via
the iOS 26.1 decompile plus a sweep of librepods, apple-wireshark, and the tyalie AAP definitions.
`0x50` is PerfStats, `0x53` a PME config blob, `0x58` an Opus mic audio stream — none is a metric.
**Loud Sound Reduction (#520) has no non-root toggle.** LSR lives on a separate raw ATT channel, not
AAP: a second L2CAP socket to **PSM 31 (`0x001F`)**, handle `0x1B`, plain `0x01`/`0x00`. Connecting
and *reading* works without Apple vendor-ID spoofing. **Writes are silently ignored** — the pods
return a Write Response (`13`) and the immediate read-back is unchanged. Reproduced back-to-back on
Pixel 8 + Pro 2 USB-C, 2026-06-10. This matches librepods only exposing the toggle behind their
root/Xposed VID-spoofing hook. A functional toggle is root-only; a read-only status indicator is
feasible today.
Not to be confused with `0x37` above — different feature, different channel, and that one is a
normal writable AAP setting.
## Session exclusivity
The pods accept exactly **one AAP session**. Any debug activity that boots the app starts
`MonitorService`, which auto-connects and wins the socket — a proof-of-concept activity will connect
at the L2CAP layer and then receive nothing. Protocol experiments have to go through the monitor's
own session, i.e. the real feature write path.
-61
View File
@@ -1,61 +0,0 @@
---
description: Load-bearing architectural invariants that are not obvious from reading the code
---
# Architecture
Invariants worth knowing before you touch device state, the AAP stack, or the upgrade flow. Class
inventories and source-set layout are omitted deliberately — read the tree for those.
## BLE vs AAP
Two independent data paths. Which one a feature can use decides whether it is even possible.
| | BLE (advertisements) | AAP (L2CAP session) |
|---|---|---|
| Direction | Read-only, passive | Bidirectional commands + events |
| Prerequisite | `BLUETOOTH_SCAN` on Android 12+, Bluetooth/location permissions below | Bonded + `BLUETOOTH_CONNECT` + active L2CAP socket |
| Data | Battery, case open, in-ear, pod model | Settings, ANC control, press controls, stem events, device info |
| Availability | Any pod in range | Only your own paired pods |
A figure BLE never advertises cannot be obtained without a bonded AAP session, and anything
requiring a write is AAP-only.
## `DeviceMonitor` is the state merge boundary
`DeviceMonitor` (singleton) `combine`s four live sources — `BlePodMonitor.devices`,
`AapConnectionManager.allStates`, `BluetoothManager2.connectedDevices` (supplies `isSystemConnected`),
and `DeviceProfilesRepo.profiles` — then merges `DeviceStateCache` on top, deliberately after the
combine so cache writes don't feed back into it.
The invariant is about **state**, not about the whole AAP layer:
- Unified device state comes from `DeviceMonitor.devices` — don't assemble your own from `BlePodMonitor`
- Commands go **through** `AapConnectionManager.sendCommand(...)`. ViewModels legitimately inject it
(`OverviewViewModel`, `DeviceSettingsViewModel`, `PressControlsViewModel` all do)
- Nothing outside the AAP engine touches `AapConnection` (the L2CAP socket wrapper) directly
- `TroubleShooterViewModel` reaching into `BlePodMonitor` for raw diagnostic scans is an intentional
exception, not a pattern to copy
Because the cache is merged in, a `PodDevice` may carry data while the device is out of range —
presence in the flow does not imply a live connection.
## `AapConnectionManager` owns sessions
It holds every open AAP session keyed by `BluetoothAddress`. Consumers call `sendCommand(...)` and
observe `allStates`.
The stack under `pods/core/apple/aap/` splits into `protocol/` (pure data) and `engine/` (per-connection
state machine). The glue in `monitor/core/aap/` wires it into the foreground service and persists
learned settings and session keys across restarts.
## FOSS is not "always pro"
`UpgradeRepo` has two flavor implementations. `UpgradeControlFoss` starts users at `isPro = false`
and only persists the pro flag after `upgrade()` is called via the local sponsor flow. Do not assume
the FOSS flavor bypasses pro gating.
## Navigation is mid-migration
Navigation3 (`addNavigation3()`) drives current Compose routing, but legacy `androidx.navigation`
helpers still exist (`NavDirectionsExtensions`, `ViewModel3`). Don't assume SafeArgs is fully gone.
-55
View File
@@ -1,55 +0,0 @@
---
description: Gradle build, test, and lint commands, and what CI actually gates
---
# Build Commands
## Quick local check
```bash
./gradlew assembleFossDebug testFossDebugUnitTest
```
`assembleFossDebug` is the fastest variant — use it for iteration.
## What CI gates
`.github/workflows/code-checks.yml`, on every PR. Core Gradle gates:
```bash
# Lint vitals — flavor x variant matrix. Note: Beta/Release only, never Debug.
./gradlew lintVitalFossBeta lintVitalFossRelease lintVitalGplayBeta lintVitalGplayRelease
# Builds — Debug only
./gradlew app:assembleFossDebug app:assembleGplayDebug
# Unit tests — both flavors
./gradlew testFossDebugUnitTest testGplayDebugUnitTest
```
Four non-Gradle checks also run, **unconditionally** — there is no path filter, so they gate your PR
even if you didn't touch those areas:
```bash
bash fastlane/check_metadata_length.sh # Play Store metadata length limits
shellcheck tools/release/bump.sh
bats tools/release/bump.bats
./tools/release/bump.sh --mode=check # version.properties + VERSION consistency
```
Reproducing those locally is usually only worth it when you changed fastlane metadata or release
tooling, but a failure there blocks the PR regardless.
**Do not run `./gradlew check` as a pre-submit gate.** It runs the full non-vital `lint` task, which
is already failing on `main` for reasons unrelated to your change — you'll burn time chasing
pre-existing findings that CI never looks at. CI gates `lintVital*`, not `lint`.
## Other commands
```bash
./gradlew assembleGplayRelease # release build
./gradlew bundleGplayRelease # Play Store bundle
./gradlew connectedFossDebugAndroidTest # instrumentation, needs a device/emulator
./gradlew lintFix # auto-fix where possible
./gradlew updateLintBaseline # refresh the baseline
```
-113
View File
@@ -1,113 +0,0 @@
---
description: Kotlin and Compose conventions — logging, ViewModel base classes, the ScreenHost/Screen split, DataStore settings
paths:
- "app/src/main/**/*.kt"
- "app/src/foss/**/*.kt"
- "app/src/gplay/**/*.kt"
- "app/src/debug/**/*.kt"
---
# Code Style
## Logging
`logTag()` builds the tag; `log()` takes a lambda so the message is only built if it's emitted.
```kotlin
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.logging.Logging.Priority.*
companion object {
private val TAG = logTag("Profiles", "Repo") // multi-part tags are the norm
}
log(TAG) { "Processing $item" } // DEBUG is the default
log(TAG, VERBOSE) { "Devices changed" }
log(TAG, ERROR) { "Failed: ${e.asLog()}" } // asLog() for stacktraces
```
Never suppress protocol logging — downgrading a level is fine, removing the call is not.
## ViewModel base classes
Four exist. Use **`ViewModel4`** for new work — it's the current one (12 subclasses) and wires
`NavigationEventSource` + `ErrorEventSource2`.
- `ViewModel4` — current, use this
- `ViewModel2` — plain base, no nav/error event sources (4 subclasses)
- `ViewModel1` — legacy (1 subclass)
- `ViewModel3`**dead, zero subclasses.** It's the `ViewModel4` shape against the older
`NavEventSource`/`ErrorEventSource` interfaces. Don't extend it.
## Compose: the Host/Screen split
Every screen is two composables.
**`<Feature>ScreenHost`** — the only place that touches `hiltViewModel()`, installs the event
handlers, and collects state.
**`<Feature>Screen`** — presentation only. Takes a plain state object plus `on*` callbacks, so it
previews without Hilt.
```kotlin
@Composable
fun SettingsScreenHost(vm: SettingsViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
state?.let {
SettingsScreen(
state = it,
onNavigateUp = { vm.navUp() },
onWiki = { vm.openUrl("https://github.com/d4rken-org/capod/wiki") },
)
}
}
@Composable
fun SettingsScreen(
state: SettingsViewModel.State,
onNavigateUp: () -> Unit,
onWiki: () -> Unit,
modifier: Modifier = Modifier, // last, after the required params
) { ... }
```
- `modifier: Modifier = Modifier` goes after the required parameters — i.e. it is the first
*optional* one, per the Compose API guidelines. capod is not fully consistent here (roughly 10
composables put it after required params, 3 put it genuinely first); match the file you're in
rather than reformatting neighbours
- The Host null-guards state; `collectAsStateWithLifecycle(initialValue = null)` is the usual shape
- Wrap previews in `PreviewWrapper` (`common/compose/PreviewWrapper.kt`), which applies `CapodTheme`
plus a background `Surface`
- Trailing commas on multi-line parameter lists and argument lists
## DataStore settings
`createValue()` is overloaded. Primitives need no serializer:
```kotlin
val monitorMode = dataStore.createValue("core.monitor.mode", MonitorMode.AUTOMATIC)
```
`@Serializable` types take a `Json`, and optionally fall back instead of throwing on corrupt or
legacy stored JSON:
```kotlin
val config = dataStore.createValue("some.config", SomeConfig(), json, onErrorFallbackToDefault = true)
```
Read and write via `.value()` / `.value(x)` (suspend) or `.flow` (reactive). Both `value` functions
are **extension functions**, not members — see `.claude/rules/testing.md` for what that means when
mocking.
## General
- Package by feature, not by layer
- Prefer adding to an existing file over creating a new one
- Prefer flow-based, cancellable solutions
- No comments for self-evident code
- Place `@Suppress` as close to the affected code as possible — on the function or constructor,
not the whole class
-40
View File
@@ -1,40 +0,0 @@
---
description: Git commit message format and conventions
---
# Commit Guidelines
## Format
```
<prefix>: <Short summary>
```
Summary line should be concise and describe the change. No period at the end.
## Prefixes
Use the existing commit history as reference. Common prefixes:
- **fix**: Bug fixes (e.g., `fix: Handle display cutouts in landscape mode`)
- **feat**: New features
- **refactor**: Code restructuring without behavior change
- **ui**: Visual/layout-only tweaks that aren't a full feature or refactor (e.g., `ui(overview): ...`)
- **chore**: Maintenance, dependency updates, build config
- **docs**: Documentation changes
## Component Scope (optional)
When a change is scoped to a specific area, include it after the prefix:
- `fix(widget): Fix layout for devices with single charge detection`
- `feat(monitor): Add battery level caching`
- `refactor(popup): Extract pod view factory`
## Rules
- Keep the summary line under 72 characters
- Use imperative mood ("Add feature" not "Added feature")
- Reference issue numbers when applicable
- Do not include `Co-authored-by` trailers
- Look at recent `git log` output to match the project's existing style
-25
View File
@@ -1,25 +0,0 @@
---
description: Guidelines for adding and naming Android string resources
paths:
- "**/res/values/strings.xml"
---
# Localization Guidelines
When adding new user-facing strings:
- **Always use string resources**: Never hardcode user-facing text in layouts or code
- **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_monitor_mode_label`)
- **Provide context**: String names should indicate usage and location
- **Consider pluralization**: Use Android plural resources (`<plurals>`) when quantities vary
## Naming Examples
- `profiles_create_title` (screen title)
- `profiles_name_label` (form field label)
- `profiles_name_default` (default value)
- `troubleshooter_ble_result_failure_title` (status/error title)
Common prefixes currently in use: `device_`, `settings_`, `support_`, `profiles_`, `press_`, `general_`, `pods_`, `upgrade_`, `widget_`, `debug_`, `permission_`, `troubleshooter_`, `overview_`, `anc_`, `onboarding_`. There is no `error_*` prefix — error labels live under the relevant feature (e.g. `general_error_label`, `troubleshooter_*_failure_*`).
String context, character limits and file context are managed on Crowdin through the android-translation plugin's `crowdin-annotate` skill. XML comments in `values/strings.xml` no longer reach translators once a string's context has been written on Crowdin; change it there.
-66
View File
@@ -1,66 +0,0 @@
---
description: Pull request title and description conventions
---
# Pull Request Guidelines
## Title
```
<Category>: <Short user-facing summary>
```
Titles appear in auto-generated changelogs and are read by users. Use ELI5, user-facing language —
no class names, library names, or implementation details. `refactor(settings): Migrate preferences
to DataStore` is the shape to avoid; `General: Remember settings between app restarts` is the shape
to use.
| Category | Covers |
|--------|--------|
| **Widget** | Home screen widget |
| **Reaction** | Case-open popup, auto-play/pause, notification triggers |
| **Device** | AirPods detection, compatibility, Bluetooth scanning, battery reading, device profiles |
| **General** | Dashboard, settings, notifications, themes, onboarding, support, app-wide UI |
| **Fix** | Bug fixes spanning multiple areas |
## Description
PRs are reviewed in GitHub's web UI, which already shows the file tree, the diff, and the tests.
The description answers what the diff can't. Use exactly these sections, in this order:
1. `## What changed`
2. `## Technical Context`
3. `## Review checklist` *(optional)*
No `Scope`, `Files changed`, `Tests`, or `Review guidance` sections.
**What changed** — user-facing explanation: the problem fixed or the feature added, from the user's
perspective. For refactors, tests, CI, and dependency bumps, write "No user-facing behavior change"
followed by a brief internal description.
**Technical Context** — one bullet per point, no prose paragraphs, no nested `**Bug 1**` headers.
Cover only what the diff can't show:
- **Why** this approach, and what was rejected
- **Root cause** for bug fixes — the diff shows the fix, not what caused it
- **Non-obvious side effects** or behavioral changes
**Review checklist**`- [ ]` items, only when there are several non-trivial things to verify.
A single tricky point stays a Technical Context bullet.
## Labels
Apply labels that match the change. Run `gh label list` to confirm what exists — do not invent new
ones. Skip labels that don't fit; no labels beats wrong labels.
- **Type**: `bug` for fixes, `enhancement` for new features or improvements
- **Transport**: `coms/AAP` when the change touches the L2CAP session path, `coms/BLE` when it
touches advertisement parsing. Both if it spans the merge in `DeviceMonitor`
- **Scope**: `device support` for new or fixed pod models, `Translations` for string/locale work,
`Build/Deploy` for CI, Gradle, and release tooling
- `Needs Info/Repro` is a triage label for issues — not for your own PRs
## Conventions
- Link issues with "Closes #123" / "Fixes #123" / "Resolves #123"
- Prefix breaking changes with "BREAKING:"
- No `Co-authored-by` trailers
-17
View File
@@ -1,17 +0,0 @@
---
description: Release guardrails — what never to do by hand. Full procedure is the /release skill.
---
# Release
The procedure lives in the `/release` skill (invoke it deliberately; it does not auto-load).
These constraints apply regardless of whether that skill was invoked.
- **`release-prepare.yml` is the only sanctioned path** for bumping a version or creating a release
tag. Do not edit `version.properties` or `VERSION` by hand, and do not create `v*` tags manually —
`validate-tag` in `release-tag.yml` rejects anything not matching `v<M.m.p>-(rc|beta)N`.
- **`tools/release/bump.sh` is the single source of truth for version logic.** Its versionCode
formula mirrors `buildSrc/src/main/java/ProjectConfig.kt` — the two must stay in sync.
- CI runs `./tools/release/bump.sh --mode=check` on every PR (`check-release-tooling` in
`code-checks.yml`). If you did touch `version.properties` or `VERSION`, run that locally first.
- All numeric version fields are bounded `0..99`; the versionCode formula collapses at ≥100.
-117
View File
@@ -1,117 +0,0 @@
---
description: Play Store screenshot pipeline — generation, copying, and adding or removing screens
paths:
- "app/src/debug/**/screenshots/**"
- "app/src/screenshotTest/**"
- "fastlane/generate_screenshots.sh"
- "fastlane/copy_screenshots.sh"
- "fastlane/Fastfile"
- "fastlane/metadata/android/*/images/phoneScreenshots/**"
---
# Play Store Screenshot Pipeline
## Overview
Localized screenshots are generated using Compose Preview Screenshot Testing (alpha), rendered offline (no device needed), and sorted into fastlane metadata directories for Play Store upload.
## Pipeline
```
ScreenshotContent.kt (mock data + composables)
→ PlayStoreScreenshots.kt (@PreviewTest entry points)
→ PlayStoreLocales.kt (multi-preview locale annotations, auto-generated per batch)
→ generate_screenshots.sh (batched Gradle runs to avoid OOM)
→ copy_screenshots.sh (sort PNGs into fastlane locale dirs)
→ fastlane/metadata/android/{locale}/images/phoneScreenshots/
```
## Key Files
| File | Purpose |
|------|---------|
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables (7 exist; `HomescreenWidgetContent` has an IDE preview only and is **not** in the Play Store pipeline) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions (currently: `DashboardLight`, `DashboardDark`, `CasePopUp`, `DeviceProfiles`, `AddProfile`, `DeviceSettingsReactions`, `WidgetConfiguration`) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations. The committed content is an en-US placeholder, not meaningful data — `generate_screenshots.sh` rewrites it per batch and restores it from a `.bak` on exit. A run killed hard leaves that `.bak` behind, so the script now refuses to start until it is restored by hand |
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
## Commit policy
Only `en-US` has `phoneScreenshots/*.png` checked into the repo — 7 PNGs, ~1 MB tracked. Every other locale is excluded by `.gitignore`.
`--smoke` still *renders* 6 locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR), but only en-US is committed. The other five cover LTR, RTL and CJK layout so a render that breaks on non-Latin script fails during generation, and the resulting PNGs sit in the working tree for manual inspection. Nothing compares them against a baseline, so this is render coverage plus eyeballing, not regression checking.
Play Store's `supply` only uploads what's present in `fastlane/metadata/android/<locale>/images/phoneScreenshots/`. For locales not in the upload, Play Store retains whatever was last pushed. So full localization on Play Store is maintained by an **occasional manual** full regen + `:screenshots_only` upload — not by every PR.
## Commands
```bash
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
# Use this for local iteration and PRs that touch screenshot content.
./fastlane/generate_screenshots.sh --smoke
# Full run — all 68 locales. Use only when intending to upload to Play Store
# (the non-smoke output is .gitignored and should not be committed).
./fastlane/generate_screenshots.sh
# Copy into fastlane directories (run after generate)
./fastlane/copy_screenshots.sh
# Clean copy (removes old screenshots first) — REQUIRED when screens are removed or renamed
./fastlane/copy_screenshots.sh --clean
```
## Adding a New Screenshot
1. Add a composable content function in `ScreenshotContent.kt` (e.g. `NewScreenContent()`)
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
5. Run the smoke pipeline: `generate_screenshots.sh --smoke` then `copy_screenshots.sh --clean`
## Removing or Renaming a Screenshot
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
2. Run `generate_screenshots.sh --smoke`
3. Run `copy_screenshots.sh --clean`**`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
## After UI Changes
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate the smoke set:
```bash
./fastlane/generate_screenshots.sh --smoke
./fastlane/copy_screenshots.sh --clean
```
## Refreshing all locales on Play Store
Periodic, manual operation — not per-PR:
```bash
./fastlane/generate_screenshots.sh # full, ~30 min, 476 PNGs (68 locales x 7)
./fastlane/copy_screenshots.sh --clean
git add fastlane/metadata/android/en-US/images/phoneScreenshots/
if bundle exec fastlane screenshots_only; then
git checkout -- fastlane/metadata/android/ &&
git commit --only -m "chore(screenshots): Refresh Play Store screenshots" -- \
fastlane/metadata/android/en-US/images/phoneScreenshots/
else
git checkout -- fastlane/metadata/android/
echo "Upload failed; the refreshed en-US screenshots remain staged for retry."
fi
```
The `git add` has to happen before the upload. The final `git checkout` restores every tracked file under that path **from the index**, so staging the refreshed English set is precisely what makes it survive the checkout — skip the `git add` and the checkout silently reverts the refresh while the store still receives the new images.
Restoring is the checkout's job otherwise: `screenshots_only` runs `remove_unsupported_languages.sh`, which deletes 9 tracked locale directories (es-AR, sc-IT, sq-AL, uz, kmr-TR, ur-IN, zu, si-LK, nb) from the working tree before uploading — 35 tracked files, a subset of the 309 tracked non-screenshot metadata files under that path, all of them put back by the checkout. It does **not** touch the regenerated non-English PNGs: those are untracked and ignored, so they stay on disk and never show up in `git status`. Because the checkout discards any uncommitted metadata text edits too, run this refresh only with an otherwise-clean metadata tree. The final commit is path-limited on purpose, so an unrelated staged change can't ride along, and it is gated on `screenshots_only` succeeding rather than merely sequenced after it: if the upload fails, the refreshed English files stay staged for a retry instead of being committed as though they were deployed. The deleted locale directories are restored on either path.
## Technical Notes
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
- Gradle daemon is stopped between batches to release memory
- `PlayStoreLocales.kt` is temporarily rewritten per batch and restored via trap
- Device spec: 1080x2400px @ 428 DPI (Pixel-class phone)
- Uses `com.android.compose.screenshot` plugin v0.0.1-alpha13
- Output: `app/src/screenshotTestGplayDebug/reference/`
-79
View File
@@ -1,79 +0,0 @@
---
description: Unit test conventions — JUnit 5, kotest assertions, mockk, BaseTest, and which Gradle task runs which source set
paths:
- "app/src/test/**"
- "app/src/testFoss/**"
- "app/src/testGplay/**"
- "app/build.gradle.kts"
- "buildSrc/src/main/java/Dependencies.kt"
---
# Testing
The stack here is not the Android default — check this before reaching for a familiar library.
## Libraries
- **JUnit 5** (`org.junit.jupiter.api.Test`). Gradle sets `useJUnitPlatform()`.
- **kotest** for assertions: `io.kotest.matchers.shouldBe`, `shouldBeNull`, `shouldBeInstanceOf`,
`shouldContainExactly`, `io.kotest.assertions.throwables.shouldThrow`. Use kotest for new
assertions — `MediaControlTest` still uses JUnit `Assertions.*` and is a legacy exception.
- **mockk** for mocking. Not Mockito.
- **Turbine is not a dependency.** `testhelpers.flow.FlowTest` provides a `Flow<T>.test()` helper —
use it rather than adding one.
## Base classes
Extend `testhelpers.BaseTest`, or the applicable specialized base that already extends it:
- `BaseBlePodsTest` — BLE advertisement parsing per pod model
- `BaseAapSessionTest` — AAP protocol/session tests
`BaseTest` installs a `JUnitLogger` and calls `unmockkAll()` in `@AfterAll`. Skipping it can leave
global mockk and logging state behind for later test classes.
The only exceptions are the Robolectric-backed tests (Compose UI via
`testhelpers.compose.BaseComposeRobolectricTest`, and the few DataStore-backed ones such as
`CurriculumVitaeProHistoryTest`), which use JUnit 4 `@RunWith`/`@Rule` via `junit-vintage-engine`.
Don't copy that pattern for a plain unit test.
## Source sets and Gradle tasks
Each task compiles and runs only its own flavor — running the wrong one silently skips your test.
| Test location | Task |
|---|---|
| `app/src/test/` (shared) | either; run both before pushing |
| `app/src/testFoss/` | `./gradlew testFossDebugUnitTest` |
| `app/src/testGplay/` | `./gradlew testGplayDebugUnitTest` |
CI runs both. Flavor-specific tests are for code that only exists in that flavor — billing in
`gplay`, the sponsor-based upgrade flow in `foss`.
## Helpers that already exist
- `runTest2(autoCancel, context, expectedError, testBody)` in `testhelpers/coroutine/TestExtensions.kt`
use `expectedError = SomeException::class` instead of hand-rolling a throws-assertion around `runTest`
- `FakeDataStoreValue<T>(initial)` in `testhelpers/datastore/` — a working fake with a real backing
`MutableStateFlow`; read/write it through `.value` and pass `.mock` to the code under test
## Mocking `DataStoreValue`
`DataStoreValue.value()` and `.value(T)` are **extension functions** (`DataStoreValue.kt:54,56`), not
members, so MockK cannot stub them. They delegate to `flow.first()` and `update { }` — stub those:
```kotlin
every { someSetting.flow } returns flowOf(value) // covers .value() reads
coVerify { someSetting.update(any()) } // verifies .value(x) writes
```
`UpgradeRepoGplayTest` uses this shape. Prefer `FakeDataStoreValue` when you need reads and writes to
actually round-trip.
## Reading ViewModel state
`ViewModel2.asLiveState()` is `stateIn(..., initialValue = null).filterNotNull()` with
`SharingStarted.WhileSubscribed(5_000)` — so `vm.state` is a `Flow`, not a `StateFlow`, and has no
`.value` to read. Collect it: `vm.state.first()` is the established pattern across the existing
ViewModel tests. Because the upstream only runs while subscribed, a test that never collects sees
nothing happen at all.
-18
View File
@@ -1,18 +0,0 @@
{
"permissions": {
"allow": [
"mcp__ide__getDiagnostics",
"WebSearch"
],
"deny": []
},
"enabledPlugins": {
"frontend-design@claude-plugins-official": true,
"android-translation@clanker-cafe": true,
"debugbadger@clanker-cafe": true,
"jvm-tools@clanker-cafe": true,
"support@clanker-cafe": true,
"google-play@clanker-cafe": true,
"devtools@clanker-cafe": true
}
}
-115
View File
@@ -1,115 +0,0 @@
---
description: Cut a capod release via the "Release prepare" workflow — dispatch inputs, channel mapping, rollback, and auth setup.
disable-model-invocation: true
argument-hint: "[bump_kind] [version_type|version_override]"
---
# Release Process
Releases are cut via the **Release prepare** workflow (`.github/workflows/release-prepare.yml`). It bumps `version.properties` and `VERSION`, commits to `main`, tags `v<version>`, pushes atomically, and dispatches `release-tag.yml` which builds, signs, and uploads.
## Required order
A real cut pushes a commit and a tag to `main` and is public the moment it lands. Do not skip ahead.
1. Run the dry run first and read its summary — never dispatch `dry_run=false` blind.
2. Report the planned version and `versionCode` back to the user.
3. Get explicit confirmation for that specific version before dispatching `dry_run=false`.
4. If the user named `bump_kind`/`version_type`/`version_override`, use exactly those. If the request
is ambiguous about which field moves, ask rather than assuming `build`.
## Dispatch
`gh workflow run` only fires the dispatch — it returns nothing about the result. The summary is
written asynchronously, so you have to go fetch it.
```bash
# Step 1 — plan only. No commit, no tag, no push. Always run this first.
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true
# Step 2 — find the run just dispatched and wait for it.
gh run list --workflow=release-prepare.yml --limit 1 # note the run id
gh run watch <run-id> --exit-status
# Step 3 — read the computed plan (version + versionCode) before going further.
gh run view <run-id> --log | tail -40
```
Report the planned version and `versionCode`, get explicit confirmation, then:
```bash
# Step 4 — real cut. Repeat the dry run's inputs EXACTLY; change only dry_run.
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=false
```
The `bump_kind=build` above is only an example. If the confirmed plan came from a `patch`/`minor`/
`major` bump, a `version_type` switch, or a `version_override`, Step 4 must carry those same flags —
otherwise you cut a different version than the one that was approved.
After `dry_run=false`: Job 1 computes + writes the summary, then Job 2 immediately commits/tags/pushes (no env gate — cancel the run between Job 1 and Job 2 if the summary looks wrong; you have ~seconds). The tag push naturally triggers `release-tag.yml` (the App-token push fires `on: push:` workflows; only `GITHUB_TOKEN`-pushes are suppressed). `release-tag.yml` then runs `validate-tag` and the existing `release-github` (`foss-production` approval) + `release-gplay` (`gplay-production` approval) jobs — those are the two human checkpoints, matching the pre-migration UX.
## Inputs
| Input | Default | Notes |
|---|---|---|
| `bump_kind` | `build` | `build` \| `patch` \| `minor` \| `major` |
| `version_type` | `keep-current` | Preserves current `rc`/`beta`. Set explicitly to switch. |
| `version_override` | empty | e.g. `5.1.2-rc0`. Bypasses bump_kind/version_type. |
| `expected_current` | empty | Optional: fail if `version.properties` ≠ this. Useful for tight coordination. |
| `dry_run` | `true` | Default is plan-only. |
Bump rules: `build` increments build; `patch`/`minor`/`major` zero everything to the right of the bumped field. All numeric fields bounded `0..99` (the `versionCode` formula collapses at ≥100).
## Local
```bash
./tools/release/bump.sh --mode=plan --bump-kind=build --version-type=keep-current
./tools/release/bump.sh --mode=check
bats tools/release/bump.bats
```
## Channel mapping
| Tag suffix | FOSS APK | GitHub release | Fastlane lane | Play track | Rollout |
|---|---|---|---|---|---|
| `-beta*` | `assembleFossBeta` | pre-release | `beta` | `beta` | 100% |
| `-rc*` | `assembleFossRelease` | full release | `production` | **`beta`** | 100% |
`release-tag.yml` accepts only `v<M.m.p>-rcN` or `v<M.m.p>-betaN` — any other suffix fails
`validate-tag` before a build starts. There is no third channel.
`lane :production` in `Fastfile` uploads to Play's **beta** track at 100% — manually promoted to production via Play Console.
## Rollback
| Stage reached | Steps |
|---|---|
| Bump on `main`, downstream not started | `git push origin :refs/tags/v<bad>`, `git revert <bump-sha>`, push |
| GitHub release created | Above + `gh release delete v<bad> --yes --cleanup-tag` |
| Play upload completed | Above + halt rollout in Play Console (or `bundle exec fastlane supply --track beta --rollout 0 --version-code <bad-code>`) |
| Job 2 ran but downstream rejected at env approval | Treat as first row — bump+tag are public on `main` regardless of downstream outcome |
`bump.sh` enforces strict `versionCode` monotonicity, so re-using a code is impossible without manually editing `version.properties`.
## Auth setup
`release-prepare.yml` Job 2 uses a GitHub App token (not `GITHUB_TOKEN`) to push the bump commit and tag. The App identity is in the rulesets' bypass list, which is what allows the push to bypass branch protection + tag-creation restrictions.
Required org secrets (set on the d4rken-org organization, accessible to `capod`):
- `RELEASE_APP_CLIENT_ID` — Client ID of the `d4rken-org-releaser` GitHub App (visible on the App's settings page, format `Iv1.<hex>` or similar)
- `RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines)
The App is installed on this repo and added as a bypass actor to:
- The main-branch ruleset (PR + status check requirements)
- The tag ruleset (creation restriction on `v*`)
Other apps in the org can reuse the same App + secrets — just install the App on each repo and add it to that repo's rulesets' bypass lists.
## Defense in depth
`release-tag.yml` includes `validate-tag` which: (1) regex-checks `github.ref_name`, (2) runs `bump.sh --mode=check`, (3) asserts the parsed name matches the tag. Manual `gh workflow run release-tag.yml --ref vfoo` or hand-pushed tags fail before any build.
## Stuck-dispatch recovery
If Job 2's atomic push lands but the natural `on: push:` trigger doesn't fire `release-tag.yml` (rare — would mean GitHub dropped the event), the tag is public but no pipeline runs. Re-dispatch manually: `gh workflow run release-tag.yml --ref v<new> -f dry_run=false`.
+13 -4
View File
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Set up JDK 17
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
with:
java-version: '17'
distribution: 'temurin'
@@ -14,7 +14,7 @@ runs:
run: chmod +x gradlew
- name: Cache Gradle Wrapper
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
with:
path: |
~/.gradle/wrapper
@@ -24,10 +24,19 @@ runs:
${{ runner.os }}-gradle-wrapper-
- name: Cache Gradle Dependencies
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
with:
path: |
~/.gradle/caches
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
restore-keys: |
${{ runner.os }}-gradle-caches-
${{ runner.os }}-gradle-caches-
- name: Cache Android Global Build-Cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
with:
path: |
~/.android/build-cache
key: ${{ runner.os }}-android-build-cache-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-android-build-cache-
+5 -51
View File
@@ -3,15 +3,9 @@ name: Code tests & eval
on:
push:
branches: [ main ]
paths-ignore:
- VERSION
- version.properties
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
lint-vital:
name: Lint vitals
@@ -24,9 +18,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Setup project and build environment
uses: ./.github/actions/common-setup
@@ -44,9 +36,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Setup project and build environment
uses: ./.github/actions/common-setup
@@ -58,50 +48,14 @@ jobs:
strategy:
fail-fast: false
matrix:
variant: [ Debug ]
variant: [ Debug,Beta,Release ]
flavor: [ testFoss,testGplay ]
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Test modules
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
check-fastlane-metadata:
name: Fastlane metadata
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Validate metadata lengths
run: bash fastlane/check_metadata_length.sh
check-release-tooling:
name: Release tooling
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Install bats and shellcheck
run: |
sudo apt-get update
sudo apt-get install -y bats shellcheck
- name: Shellcheck bump.sh
run: shellcheck tools/release/bump.sh
- name: Run bump.sh unit tests
run: bats tools/release/bump.bats
- name: Verify version.properties + VERSION are consistent
run: ./tools/release/bump.sh --mode=check
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
@@ -4,22 +4,14 @@ on:
push:
branches:
- main
paths-ignore:
- VERSION
- version.properties
pull_request:
branches:
- main
permissions:
contents: read
jobs:
validation:
name: "Validation"
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 #v4.3.1
-66
View File
@@ -1,66 +0,0 @@
name: Deploy GitHub Pages
on:
push:
branches: [ main ]
paths:
- _config.yml
- _layouts/**
- README.md
- CHANGELOG.md
- PRIVACY_POLICY.md
- CNAME
- .github/workflows/pages.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
name: Build Jekyll site
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d #v6.0.0
- name: Build with Jekyll
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 #v1.0.13
with:
source: ./
destination: ./_site
env:
JEKYLL_GITHUB_TOKEN: ${{ github.token }}
- name: Sanity-check Jekyll output
run: test -f _site/index.html && test -f _site/CNAME
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 #v5.0.0
with:
path: ./_site
deploy:
name: Deploy to GitHub Pages
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 #v5.0.0
-235
View File
@@ -1,235 +0,0 @@
name: Release prepare
on:
workflow_dispatch:
inputs:
bump_kind:
description: 'How to bump the version (ignored if version_override is set)'
type: choice
options: [build, patch, minor, major]
default: build
version_type:
description: 'Channel for the new version (keep-current preserves current type)'
type: choice
options: [keep-current, rc, beta]
default: keep-current
version_override:
description: 'Explicit version, e.g. 5.1.2-rc0 (overrides bump_kind/version_type)'
type: string
default: ''
expected_current:
description: 'Optional safety check: fail if current version.properties does not match (e.g. 5.1.1-rc0)'
type: string
default: ''
dry_run:
description: 'When true: compute and validate only. When false: commit, tag, push, dispatch release-tag.yml.'
type: boolean
default: true
permissions:
contents: read
concurrency:
group: release-prepare-main
cancel-in-progress: false
jobs:
compute-and-validate:
name: Compute and validate
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
new_name: ${{ steps.plan.outputs.new_name }}
new_code: ${{ steps.plan.outputs.new_code }}
current_name: ${{ steps.plan.outputs.current_name }}
env:
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
INPUT_EXPECTED_CURRENT: ${{ inputs.expected_current }}
steps:
- name: Guard ref must be main
run: |
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
echo "Must dispatch from main, got ${GITHUB_REF}" >&2
exit 1
fi
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Compute and validate
id: plan
run: |
set -euo pipefail
args=("--mode=plan")
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
else
args+=("--bump-kind=${INPUT_BUMP_KIND}")
args+=("--version-type=${INPUT_VERSION_TYPE}")
fi
if [[ -n "${INPUT_EXPECTED_CURRENT}" ]]; then
args+=("--expected-current=${INPUT_EXPECTED_CURRENT}")
fi
./tools/release/bump.sh "${args[@]}" | tee plan.txt
{
grep -E '^current_name=' plan.txt
grep -E '^new_name=' plan.txt
grep -E '^new_code=' plan.txt
} >> "$GITHUB_OUTPUT"
- name: Tag collision check (local + remote)
env:
NEW_NAME: ${{ steps.plan.outputs.new_name }}
run: |
set -euo pipefail
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
echo "Local tag v${NEW_NAME} already exists" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
echo "Remote tag v${NEW_NAME} already exists" >&2
exit 1
fi
- name: Write step summary
env:
CURRENT_NAME: ${{ steps.plan.outputs.current_name }}
NEW_NAME: ${{ steps.plan.outputs.new_name }}
NEW_CODE: ${{ steps.plan.outputs.new_code }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
{
echo "## Release plan"
echo
echo "| | |"
echo "|---|---|"
echo "| Current | \`${CURRENT_NAME}\` |"
echo "| New | \`${NEW_NAME}\` (code ${NEW_CODE}) |"
echo "| Tag | \`v${NEW_NAME}\` |"
echo "| Dry run | \`${DRY_RUN}\` |"
echo
echo "### bump.sh output"
echo
echo '```'
cat plan.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
push-and-dispatch:
name: Push and dispatch
needs: compute-and-validate
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-22.04
permissions:
contents: read
env:
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
NEW_NAME: ${{ needs.compute-and-validate.outputs.new_name }}
NEW_CODE: ${{ needs.compute-and-validate.outputs.new_code }}
CURRENT_NAME_AT_PLAN: ${{ needs.compute-and-validate.outputs.current_name }}
steps:
- name: Mint App token
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
with:
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Resolve bot identity
id: bot
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
set -euo pipefail
user_id=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id)
echo "user_name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT"
echo "user_email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT"
- name: Checkout main with credentials
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
persist-credentials: true
token: ${{ steps.app-token.outputs.token }}
- name: Verify state still matches plan from Job 1
run: |
set -euo pipefail
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
- name: Re-check tag collision (state may have moved between jobs)
run: |
set -euo pipefail
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
echo "Local tag v${NEW_NAME} already exists" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
echo "Remote tag v${NEW_NAME} already exists" >&2
exit 1
fi
- name: Apply bump
run: |
set -euo pipefail
args=("--mode=write" "--expected-current=${CURRENT_NAME_AT_PLAN}")
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
else
args+=("--bump-kind=${INPUT_BUMP_KIND}")
args+=("--version-type=${INPUT_VERSION_TYPE}")
fi
./tools/release/bump.sh "${args[@]}"
- name: Verify post-write state matches plan
run: |
set -euo pipefail
./tools/release/bump.sh --mode=check --expected-current="${NEW_NAME}"
- name: Configure git identity
env:
BOT_USER_NAME: ${{ steps.bot.outputs.user_name }}
BOT_USER_EMAIL: ${{ steps.bot.outputs.user_email }}
run: |
git config user.name "${BOT_USER_NAME}"
git config user.email "${BOT_USER_EMAIL}"
- name: Commit and tag
run: |
set -euo pipefail
git add version.properties VERSION
git commit -m "Release: ${NEW_NAME}"
git tag -a "v${NEW_NAME}" -m "Release v${NEW_NAME}"
- name: Atomic push (commit + tag)
run: |
set -euo pipefail
git push --atomic origin "HEAD:refs/heads/main" "refs/tags/v${NEW_NAME}"
- name: Write step summary
run: |
set -euo pipefail
{
echo "## Released"
echo
echo "| | |"
echo "|---|---|"
echo "| Tag | \`v${NEW_NAME}\` |"
echo "| Version code | \`${NEW_CODE}\` |"
echo "| Bump commit | on \`main\` |"
echo "| Downstream | dispatched \`release-tag.yml\` |"
echo
echo "Watch the [Tagged releases](../../actions/workflows/release-tag.yml) workflow for the build + upload."
} >> "$GITHUB_STEP_SUMMARY"
+51 -95
View File
@@ -4,64 +4,12 @@ on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
dry_run:
description: 'Build only, skip release/upload'
type: boolean
default: true
permissions:
contents: read
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
validate-tag:
name: Validate tag
runs-on: ubuntu-22.04
steps:
- name: Check tag-name format
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ ! "${REF_NAME}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}-(rc|beta)[0-9]{1,2}$ ]]; then
echo "Tag '${REF_NAME}' does not match v<M.m.p-(rc|beta)n>" >&2
echo "Releases must be cut via the 'Release prepare' workflow." >&2
exit 1
fi
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
fetch-depth: 1
persist-credentials: false
- name: Verify version.properties matches tag
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
# Strip leading 'v' to get the bare version name.
tag_name="${REF_NAME#v}"
# bump.sh in check mode emits current_name=...
parsed=$(./tools/release/bump.sh --mode=check)
current_name=$(echo "$parsed" | grep -E '^current_name=' | cut -d= -f2)
if [[ "$current_name" != "$tag_name" ]]; then
echo "Tag '${REF_NAME}' does not match version.properties name '$current_name'" >&2
echo "version.properties + VERSION must equal the tag — releases must be cut via 'Release prepare'." >&2
exit 1
fi
release-github:
needs: validate-tag
name: Create GitHub release
permissions:
contents: write
actions: write
runs-on: ubuntu-22.04
environment: foss-production
steps:
@@ -76,16 +24,23 @@ jobs:
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Get the version
id: tagger
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
with:
skip-unshallow: 'true'
abbrev: false
commit-ish: HEAD
- name: Assemble beta APK
if: contains(github.ref_name, '-beta')
if: contains(steps.tagger.outputs.tag, '-beta')
run: ./gradlew assembleFossBeta
env:
VERSION: ${{ github.ref }}
@@ -94,7 +49,7 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble production APK
if: "!contains(github.ref_name, '-beta')"
if: "!contains(steps.tagger.outputs.tag, '-beta')"
run: ./gradlew assembleFossRelease
env:
VERSION: ${{ github.ref }}
@@ -103,42 +58,35 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Create pre-release
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
if: contains(steps.tagger.outputs.tag, '-beta')
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
with:
prerelease: true
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
tag_name: ${{ steps.tagger.outputs.tag }}
name: ${{ steps.tagger.outputs.tag }}
generate_release_notes: true
files: app/build/outputs/apk/foss/beta/eu.darken.capod-*.apk
files: |
app/build/outputs/apk/foss/beta/*.apk
app-wear/build/outputs/apk/foss/beta/*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
if: "!contains(steps.tagger.outputs.tag, '-beta')"
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
with:
prerelease: false
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
tag_name: ${{ steps.tagger.outputs.tag }}
name: ${{ steps.tagger.outputs.tag }}
generate_release_notes: true
files: app/build/outputs/apk/foss/release/eu.darken.capod-*.apk
files: app/build/outputs/apk/foss/release/*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger GitHub Pages deployment
if: "!(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run pages.yml --ref main
release-gplay:
needs: validate-tag
name: Create Google Play release
runs-on: ubuntu-22.04
environment: gplay-production
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile
steps:
- name: Decode Keystore
env:
@@ -161,47 +109,55 @@ jobs:
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Get the version
id: tagger
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
with:
skip-unshallow: 'true'
abbrev: false
commit-ish: HEAD
- name: Set up ruby env
uses: ruby/setup-ruby@4eb9f110bac952a8b68ecf92e3b5c7a987594ba6 #v1.292.0
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 #v1.229.0
with:
ruby-version: 3.3.6
bundler-cache: true
working-directory: fastlane
- name: Verify fastlane Bundler wiring
run: bundle exec fastlane --version
# - name: Assemble WearOS beta and upload to Google Play
# if: contains(steps.tagger.outputs.tag, '-beta')
# run: bundle exec fastlane beta_wearos
# env:
# STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
# KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
# KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble beta and upload to Google Play
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
if: contains(steps.tagger.outputs.tag, '-beta')
run: bundle exec fastlane beta
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
# - name: Assemble WearOS production and upload to Google Play
# if: "!contains(steps.tagger.outputs.tag, '-beta')"
# run: bundle exec fastlane production_wearos
# env:
# STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
# KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
# KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble production and upload to Google Play
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
if: "!contains(steps.tagger.outputs.tag, '-beta')"
run: bundle exec fastlane production
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
# The Play flavor is obfuscated; the bundle embeds this mapping, but keep it reachable
# for retracing user-submitted logs without going through Play Console.
- name: Archive R8 mapping
if: always() && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: r8-mapping-${{ github.ref_name }}
path: app/build/outputs/mapping/gplay*/mapping.txt
if-no-files-found: warn
overwrite: true
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
-26
View File
@@ -1,26 +0,0 @@
name: Thumbnail images
# Shrinks oversized screenshots in issues and comments into clickable
# thumbnails. The logic lives in d4rken-org/.github so every app repo shares
# one copy; this stub only supplies the triggers, because workflow_call cannot
# be triggered by issue_comment directly.
#
# The shared workflow splits into an unprivileged parsing job and a privileged
# job that only makes the API call, so issues:write below is the ceiling, not
# what the parsing half receives.
#
# Add <!-- no-thumbnail --> to a body to opt it out.
on:
issue_comment:
types: [created, edited]
issues:
types: [opened, edited]
permissions: {}
jobs:
thumbnail:
permissions:
issues: write
uses: d4rken-org/.github/.github/workflows/thumbnail-images.yml@94e36d9a67a887e24338f95fd0429925cea21c98
+1 -15
View File
@@ -12,18 +12,4 @@
*.jks
.local/*
/fastlane/report.xml
/fastlane/Appfile
/fastlane/README.md
.kotlin
# Screenshot test reference images (ephemeral, regenerated on demand)
app/src/screenshotTest*/reference/
# Play Store phone screenshots: commit only en-US.
# Full localization is uploaded by occasional manual regen + screenshots_only;
# Play Store retains previously-uploaded screenshots for locales not pushed.
fastlane/metadata/android/*/images/phoneScreenshots/*.png
!fastlane/metadata/android/en-US/images/phoneScreenshots/*.png
.codex
protocol-research/
_site/
.jekyll-cache/
vendor/bundle/
/fastlane/Appfile
-90
View File
@@ -1,90 +0,0 @@
---
layout: plain
permalink: /changelog
title: "Changelog"
---
# Changelog for CAPod
{% for release in site.github.releases %}
## {{ release.tag_name }} - {{ release.published_at | date: "%B %d, %Y" }}
{% assign clean_body = release.body | strip %}
{% assign no_comments = clean_body | replace: "<!-- Release notes generated using configuration in .github/release.yml", "" %}
{% assign no_comments = no_comments | split: "-->" %}
{% if no_comments.size > 1 %}
{% assign clean_content = no_comments[1] | strip %}
{% else %}
{% assign clean_content = no_comments[0] | strip %}
{% endif %}
{% comment %} Make links clickable {% endcomment %}
{% assign lines = clean_content | split: "
" %}
{% assign processed_lines = "" %}
{% for line in lines %}
{% if line contains "**Full Changelog**:" %}
{% comment %} Handle Full Changelog links {% endcomment %}
{% assign parts = line | split: ": " %}
{% if parts.size > 1 %}
{% assign url = parts[1] | strip %}
{% assign clickable_line = "**[View Changes](" | append: url | append: ")**" %}
{% assign processed_lines = processed_lines | append: clickable_line | append: "
" %}
{% else %}
{% assign processed_lines = processed_lines | append: line | append: "
" %}
{% endif %}
{% elsif line contains " in https://github.com/" and line contains "/pull/" %}
{% comment %} Handle pull request links {% endcomment %}
{% assign pr_parts = line | split: " in https://github.com/" %}
{% if pr_parts.size > 1 %}
{% assign before_url = pr_parts[0] %}
{% assign after_url = pr_parts[1] %}
{% assign url = "https://github.com/" | append: after_url %}
{% assign pr_number = after_url | split: "/pull/" %}
{% if pr_number.size > 1 %}
{% assign pr_num = pr_number[1] | split: " " | first %}
{% assign clickable_line = before_url | append: " in [#" | append: pr_num | append: "](" | append: url | append: ")" %}
{% assign processed_lines = processed_lines | append: clickable_line | append: "
" %}
{% else %}
{% assign processed_lines = processed_lines | append: line | append: "
" %}
{% endif %}
{% else %}
{% assign processed_lines = processed_lines | append: line | append: "
" %}
{% endif %}
{% else %}
{% assign processed_lines = processed_lines | append: line | append: "
" %}
{% endif %}
{% endfor %}
{% comment %} Add proper spacing between sections and bullet points {% endcomment %}
{% assign final_content = processed_lines | replace: "
### ", "
### " %}
{% assign final_content = final_content | replace: "
## ", "
## " %}
{% assign final_content = final_content | replace: "
- ", "
- " %}
{% comment %} Check if there are any bullet points (actual release notes) {% endcomment %}
{% if final_content contains "## " or final_content contains "- " %}
{{ final_content | markdownify }}
{% else %}
*No release notes available.*
{{ final_content | markdownify }}
{% endif %}
---
{% endfor %}
View File
+218
View File
@@ -0,0 +1,218 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
rexml
addressable (2.8.4)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.784.0)
aws-sdk-core (3.177.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.70.0)
aws-sdk-core (~> 3, >= 3.177.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.128.0)
aws-sdk-core (~> 3, >= 3.177.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.6)
aws-sigv4 (1.6.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.100.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.7)
fastlane (2.213.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (>= 1.4.5, < 2.0.0)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.45.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.19.0)
google-apis-core (>= 0.9.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.6.0)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.6.3)
jwt (2.7.1)
memoist (0.16.2)
mini_magick (4.12.0)
mini_mime (1.1.2)
multi_json (1.15.0)
multipart-post (2.3.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.1.1)
os (1.1.4)
plist (3.7.0)
public_suffix (5.0.1)
rake (13.0.6)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.5)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.17.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (1.8.0)
webrick (1.8.1)
word_wrap (1.0.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
x86_64-linux
DEPENDENCIES
fastlane
BUNDLED WITH
2.2.8
+9 -20
View File
@@ -1,9 +1,8 @@
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400" alt="CAPod banner">
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400">
# Companion App for AirPods (CAPod)
[![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases)](https://github.com/d4rken-org/capod/releases/latest)
[![RB Status](https://shields.rbtlog.dev/simple/eu.darken.capod)](https://shields.rbtlog.dev/eu.darken.capod)
[![Code tests & eval](https://github.com/d4rken-org/capod/actions/workflows/code-checks.yml/badge.svg)](https://github.com/d4rken/capod/actions/workflows/code-checks.yml)
[![Crowdin](https://badges.crowdin.net/capod/localized.svg)](https://crowdin.com/project/capod)
[![Github Downloads](https://img.shields.io/github/downloads/d4rken-org/capod/total.svg?label=GitHub%20Downloads&logo=github)](https://github.com/d4rken-org/capod/edit/main/README.md#download)
@@ -18,6 +17,7 @@ A companion app that adds support for AirPod specific features to Android:
* Ear detection with automatic play/pause.
* Automatically connect phone & AirPods.
* Show popup when case is opened.
* Support for Wear OS
* Widgets
CAPod is ad-free. Some additional features require an in-app purchase.
@@ -32,22 +32,12 @@ Currently supported models:
* AirPods Pro 1. Generation
* AirPods Pro 2. Generation
* AirPods Pro 2. Generation (USB-C)
* AirPods Pro 3. Generation
* AirPods Max
* AirPods Max (USB-C)
* AirPods Max 2. Generation
* Power Beats Pro
* Power Beats Pro 2
* Power Beats 3
* Power Beats 4
* Beats Solo 3
* Beats Solo Pro
* Beats Solo 4
* Beats Solo Buds
* Beats Studio 3
* Beats Studio Buds
* Beats Studio Buds+
* Beats Studio Pro
* Beats X
* Beats Flex
* Beats Fit Pro
@@ -61,12 +51,11 @@ Currently supported models:
| Source | Status |
|------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [![https://play.google.com/store/apps/details?id=eu.darken.capod](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Deu.darken.capod%26l%3DGoogle%2520Play%26m%3D%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) |
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [![https://play.google.com/apps/testing/eu.darken.capod](https://img.shields.io/badge/Google%20Play-Beta-yellowgreen?style=flat&logo=google-play)](https://play.google.com/apps/testing/eu.darken.capod) | |
| [Github (Release)](https://github.com/d4rken-org/capod/releases) | ![https://github.com/d4rken-org/capod/releases](https://img.shields.io/github/v/release/d4rken-org/capod?display_name=release&logo=github&label=GitHub%20(Release)) ![](https://img.shields.io/github/downloads/d4rken-org/capod/latest/total?label=%20) |
| [Github (Pre-Release)](https://github.com/d4rken-org/capod/releases) | ![https://github.com/d4rken-org/capod/releases](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases&display_name=release&logo=github&label=GitHub%20(Pre-Release)) ![](https://img.shields.io/github/downloads-pre/d4rken-org/capod/latest/total?label=%20) |
| [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [![https://f-droid.org/en/packages/eu.darken.capod/](https://img.shields.io/f-droid/v/eu.darken.capod)](https://f-droid.org/en/packages/eu.darken.capod/) |
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [![https://apt.izzysoft.de/packages/eu.darken.capod/](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/eu.darken.capod)](https://apt.izzysoft.de/packages/eu.darken.capod/) |
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [![](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Deu.darken.capod%26l%3DGoogle%2520Play%26m%3D%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) |
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [![](https://img.shields.io/badge/Google%20Play-Beta-yellowgreen?style=flat&logo=google-play)](https://play.google.com/apps/testing/eu.darken.capod) | |
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases&label=GitHub)](https://github.com/d4rken-org/capod/releases/latest) |
| [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [![](https://img.shields.io/f-droid/v/eu.darken.capod)](https://f-droid.org/en/packages/eu.darken.capod/) |
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [![](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/eu.darken.capod)](https://apt.izzysoft.de/packages/eu.darken.capod/) |
## Support the project
@@ -81,7 +70,8 @@ Currently supported models:
## Screenshots
<img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1_dashboard_light.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/2_dashboard_dark.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3_case_popup.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/4_widget_configuration.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5_device_profiles.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/6_add_profile.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/7_device_settings_reactions.png" width="100">
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
## Thanks to
@@ -95,7 +85,6 @@ Currently supported models:
check it out.
* [@kavishdevar](https://github.com/kavishdevar/librepods) and
his [LibrePods project](https://github.com/kavishdevar/librepods) for sharing a lot of cool stuff.
* [Crowdin](https://crowdin.com/) for supporting open-source projects.
## License
+1 -1
View File
@@ -1 +1 @@
5.2.5-rc0 50205000
2.17.1-rc0 21701000
+4 -6
View File
@@ -1,9 +1,6 @@
theme: minima
plugins:
- jekyll-relative-links
- jekyll-github-metadata
- jemoji
relative_links:
enabled: true
collections: true
@@ -14,12 +11,13 @@ author: "by Matthias Urhahn"
include:
- PRIVACY_POLICY.md
- README.md
- CHANGELOG.md
exclude:
- buildSrc
- gradle/wrapper
- fastlane
- Gemfile
- Gemfile.lock
- crowdin*
- app
- CONTRIBUTING.md
- app-common
- app-wear
+1
View File
@@ -0,0 +1 @@
/build
+100
View File
@@ -0,0 +1,100 @@
plugins {
id("com.android.library")
id("kotlin-android")
id("com.google.devtools.ksp")
id("kotlin-kapt")
id("kotlin-parcelize")
}
apply(plugin = "dagger.hilt.android.plugin")
android {
compileSdk = ProjectConfig.compileSdk
namespace = "${ProjectConfig.packageName}.common"
defaultConfig {
minSdk = ProjectConfig.minSdk
targetSdk = ProjectConfig.targetSdk
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
buildConfigField("Long", "VERSION_CODE", "${ProjectConfig.Version.code}L")
buildConfigField("String", "VERSION_NAME", "\"${ProjectConfig.Version.name}\"")
buildConfigField("String", "APPLICATION_ID", "\"${ProjectConfig.packageName}\"")
buildConfigField("String", "GITSHA", "\"${lastCommitHash()}\"")
buildConfigField("String", "BUILDTIME", "\"${buildTime()}\"")
}
buildFeatures {
viewBinding = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.ExperimentalStdlibApi",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlin.time.ExperimentalTime",
"-opt-in=kotlin.ExperimentalUnsignedTypes",
)
}
flavorDimensions.add("version")
productFlavors {
create("foss") {
dimension = "version"
}
create("gplay") {
dimension = "version"
}
}
buildTypes {
val customProguardRules = fileTree(File("../proguard")) {
include("*.pro")
}
debug {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
proguardFiles(*customProguardRules.toList().toTypedArray())
proguardFiles("proguard-rules-debug.pro")
}
create("beta") {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
proguardFiles(*customProguardRules.toList().toTypedArray())
}
release {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
proguardFiles(*customProguardRules.toList().toTypedArray())
}
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
tasks.withType<Test> {
useJUnitPlatform()
}
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
addBaseAndroid()
addBaseAndroidUi()
addBaseKotlin()
addDagger()
addMoshi()
addBaseWorkManager()
addNavigation()
addTesting()
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>
@@ -5,6 +5,7 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.debug.autoreport.FossAutoReporting
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
<application>
</application>
</manifest>
@@ -12,7 +12,6 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import javax.inject.Inject
import javax.inject.Singleton
import eu.darken.capod.common.datastore.valueBlocking
@Singleton
class GplayAutoReporting @Inject constructor(
@@ -22,7 +21,7 @@ class GplayAutoReporting @Inject constructor(
) : AutomaticBugReporter {
override fun setup(application: Application) {
val isEnabled = debugSettings.isAutoReportingEnabled.valueBlocking
val isEnabled = debugSettings.isAutoReportingEnabled.value
log(TAG) { "setup(): isEnabled=$isEnabled" }
if (!isEnabled) return
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.darken.capod.common">
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<application>
<receiver
android:name=".bluetooth.BleScanResultReceiver"
android:exported="false">
<intent-filter>
<action android:name="eu.darken.capod.bluetooth.DELIVER_SCAN_RESULTS" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -1,13 +1,10 @@
import android.content.BroadcastReceiver
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
fun BroadcastReceiver.PendingResult.finish2(): Boolean = try {
finish()
true
} catch (e: IllegalStateException) {
log(TAG) { "BroadcastReceiver.PendingResult.finish() failed: $e" }
log { "BroadcastReceiver.PendingResult.finish() failed: $e" }
false
}
private val TAG = logTag("Common", "BroadcastReceiver")
}
@@ -0,0 +1,45 @@
package eu.darken.capod.common
// Can't be const because that prevents them from being mocked in tests
@Suppress("MayBeConstant")
object BuildConfigWrap {
val DEBUG: Boolean = BuildConfig.DEBUG
val BUILD_TYPE: BuildType = when (val typ = BuildConfig.BUILD_TYPE) {
"debug" -> BuildType.DEV
"beta" -> BuildType.BETA
"release" -> BuildType.RELEASE
else -> throw IllegalArgumentException("Unknown buildtype: $typ")
}
enum class BuildType {
DEV,
BETA,
RELEASE,
;
}
val FLAVOR: Flavor = when (val flav = BuildConfig.FLAVOR) {
"gplay" -> Flavor.GPLAY
"foss" -> Flavor.FOSS
else -> throw IllegalStateException("Unknown flavor: $flav")
}
enum class Flavor {
GPLAY,
FOSS,
;
}
val APPLICATION_ID: String = BuildConfig.APPLICATION_ID
val VERSION_CODE: Long = BuildConfig.VERSION_CODE.toLong()
val VERSION_NAME: String = BuildConfig.VERSION_NAME
val GIT_SHA: String = BuildConfig.GITSHA
val BUILDTIME: String = BuildConfig.BUILDTIME
val VERSION_DESCRIPTION_LONG: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA] ${FLAVOR}_$BUILD_TYPE"
val VERSION_DESCRIPTION_SHORT: String = "v$VERSION_NAME [$GIT_SHA] $FLAVOR"
val VERSION_DESCRIPTION_TINY: String = "v$VERSION_NAME"
}
@@ -0,0 +1,40 @@
package eu.darken.capod.common
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.res.TypedArray
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
@ColorInt
fun Context.getColorForAttr(@AttrRes attrId: Int): Int {
var typedArray: TypedArray? = null
try {
typedArray = this.theme.obtainStyledAttributes(intArrayOf(attrId))
return typedArray.getColor(0, 0)
} finally {
typedArray?.recycle()
}
}
@ColorInt
fun Fragment.getColorForAttr(@AttrRes attrId: Int): Int = requireContext().getColorForAttr(attrId)
@ColorInt
fun Context.getCompatColor(@ColorRes attrId: Int): Int {
return ContextCompat.getColor(this, attrId)
}
@ColorInt
fun Fragment.getCompatColor(@ColorRes attrId: Int): Int = requireContext().getCompatColor(attrId)
@SuppressLint("NewApi")
fun Context.startServiceCompat(intent: Intent): ComponentName? {
return if (hasApiLevel(26)) startForegroundService(intent) else startService(intent)
}
@@ -5,7 +5,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import java.io.File
import java.util.UUID
import java.util.*
import java.util.regex.Pattern
import javax.inject.Inject
import javax.inject.Singleton
@@ -0,0 +1,59 @@
package eu.darken.capod.common
import android.media.AudioManager
import android.os.SystemClock
import android.view.KeyEvent
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.delay
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MediaControl @Inject constructor(
private val audioManager: AudioManager,
) {
val isPlaying: Boolean
get() = audioManager.isMusicActive
suspend fun sendPlay() {
log(TAG, INFO) { "sendPlay()" }
if (audioManager.isMusicActive) {
log(TAG, INFO) { "Music is already playing, not sending play" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
}
suspend fun sendPause() {
log(TAG, INFO) { "sendPause()" }
if (!audioManager.isMusicActive) {
log(TAG, INFO) { "Music is not playing, not sending pause" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
}
suspend fun sendPlayPause() {
log(TAG) { "sendPlayPause()" }
if (audioManager.isMusicActive) {
sendPause()
} else {
sendPlay()
}
}
private suspend fun sendKey(keyCode: Int) {
log(TAG) { "Sending up+down KeyEvent: $keyCode" }
val eventTime = SystemClock.uptimeMillis()
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0))
delay(100)
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime + 200, eventTime + 200, KeyEvent.ACTION_UP, keyCode, 0))
}
companion object {
private val TAG = logTag("MediaControl")
}
}
@@ -0,0 +1,9 @@
package eu.darken.capod.common
import android.os.SystemClock
object SystemClockWrap {
val elapsedRealtimeNanos: Long
get() = SystemClock.elapsedRealtimeNanos()
}
@@ -9,29 +9,21 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import javax.inject.Inject
import eu.darken.capod.common.debug.logging.logTag
@Reusable
class WebpageTool @Inject constructor(
@ApplicationContext private val context: Context,
) {
// Returns whether an activity was actually started, so callers that gate behaviour on the page
// having opened (e.g. the FOSS sponsor unlock heuristic) don't fire when no browser handled it.
fun open(address: String): Boolean {
fun open(address: String) {
val intent = Intent(Intent.ACTION_VIEW, address.toUri()).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
return try {
try {
context.startActivity(intent)
true
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to launch: ${e.asLog()}" }
false
log(ERROR) { "Failed to launch: ${e.asLog()}" }
}
}
companion object {
private val TAG = logTag("WebpageTool")
}
}
@@ -3,23 +3,19 @@ package eu.darken.capod.common.bluetooth
import android.bluetooth.le.ScanResult
import android.os.Parcelable
import androidx.core.util.forEach
import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import eu.darken.capod.common.serialization.MapIntByteArrayBase64Serializer
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Parcelize
@Serializable
@JsonClass(generateAdapter = true)
data class BleScanResult(
@SerialName("receivedAt") @Serializable(with = InstantEpochMillisSerializer::class) val receivedAt: Instant,
@SerialName("address") val address: String,
@SerialName("rssi") val rssi: Int,
@SerialName("generatedAtNanos") val generatedAtNanos: Long,
@SerialName("manufacturerSpecificData") @Serializable(with = MapIntByteArrayBase64Serializer::class) val manufacturerSpecificData: Map<Int, ByteArray>
@Json(name = "receivedAt") val receivedAt: Instant,
@Json(name = "address") val address: String,
@Json(name = "rssi") val rssi: Int,
@Json(name = "generatedAtNanos") val generatedAtNanos: Long,
@Json(name = "manufacturerSpecificData") val manufacturerSpecificData: Map<Int, ByteArray>
) : Parcelable {
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
@@ -33,11 +29,8 @@ data class BleScanResult(
}
companion object {
fun fromScanResult(
scanResult: ScanResult,
timeSource: TimeSource = SystemTimeSource,
) = BleScanResult(
receivedAt = timeSource.now(),
fun fromScanResult(scanResult: ScanResult) = BleScanResult(
receivedAt = Instant.now(),
address = scanResult.device.address,
rssi = scanResult.rssi,
generatedAtNanos = scanResult.timestampNanos,
@@ -48,4 +41,4 @@ data class BleScanResult(
}
)
}
}
}
@@ -22,12 +22,12 @@ class BleScanResultForwarder @Inject constructor() {
val results: Flow<Collection<ScanResult>> = forwarder
fun forward(scanResults: Collection<ScanResult>) {
log(TAG, VERBOSE) { "forward(${scanResults.logSummary()})" }
log(TAG, VERBOSE) { "forward($scanResults)" }
val success = forwarder.tryEmit(scanResults)
if (!success) log(TAG, WARN) { "Failed to forward (overflow?) ${scanResults.logSummary()}" }
if (!success) log(TAG, WARN) { "Failed to forward (overflow?) $scanResults" }
}
companion object {
private val TAG = logTag("Bluetooth", "BleScanner", "Forwarder")
}
}
}
@@ -22,7 +22,7 @@ class BleScanResultReceiver : BroadcastReceiver() {
@Inject lateinit var scanResultForwarder: BleScanResultForwarder
override fun onReceive(context: Context, intent: Intent) {
log(TAG, VERBOSE) { "onReceive(action=${intent.action})" }
log(TAG, VERBOSE) { "onReceive($context, $intent)" }
if (intent.action != ACTION) {
log(TAG, WARN) { "Unknown action: ${intent.action}" }
return
@@ -43,7 +43,7 @@ class BleScanResultReceiver : BroadcastReceiver() {
log(TAG, VERBOSE) { "callbackType=$callbackType" }
val scanResults = intent.getParcelableArrayListExtra<ScanResult>(BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT)
log(TAG, VERBOSE) { "scanResults=${scanResults?.logSummary() ?: "count=0"}" }
log(TAG, VERBOSE) { "scanResults=$scanResults" }
if (scanResults == null) {
log(TAG) { "Scan results were empty!" }
@@ -0,0 +1,213 @@
package eu.darken.capod.common.bluetooth
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BleScanner @Inject constructor(
@ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2,
private val fakeBleData: FakeBleData,
private val scanResultForwarder: BleScanResultForwarder,
) {
@SuppressLint("MissingPermission") fun scan(
filters: Set<ScanFilter>,
scannerMode: ScannerMode = ScannerMode.BALANCED,
disableOffloadFiltering: Boolean = false,
disableOffloadBatching: Boolean = false,
disableDirectScanCallback: Boolean = false,
): Flow<Collection<BleScanResult>> = callbackFlow {
log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" }
val adapter = bluetoothManager.adapter ?: throw IllegalStateException("Bluetooth adapter unavailable")
val useOffloadedFiltering = adapter.isOffloadedFilteringSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" }
} && !disableOffloadFiltering
if (disableOffloadFiltering) log(TAG, WARN) { "Offloaded filtering is disabled!" }
val useOffloadedBatching = adapter.isOffloadedScanBatchingSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" }
} && !disableOffloadBatching
if (disableOffloadBatching) log(TAG, WARN) { "Offloaded scan-batching is disabled!" }
if (disableDirectScanCallback) log(TAG, WARN) { "Direct scan callback is disabled!" }
val scanner = bluetoothManager.scanner ?: throw IllegalStateException("BLE scanner unavailable")
val filterResults: (Collection<ScanResult>) -> Collection<BleScanResult> = { results ->
results
.filter { result ->
val passed = when {
useOffloadedFiltering -> true
filters.isEmpty() -> true
else -> filters.any { it.matches(result) }
}
if (!passed) log(TAG, VERBOSE) { "Manually filtered $result" }
passed
}
.map { BleScanResult.fromScanResult(it) }
}
val callback = object : ScanCallback() {
var lastScanAt = System.currentTimeMillis()
override fun onScanResult(callbackType: Int, result: ScanResult) {
log(TAG, VERBOSE) {
val delay = System.currentTimeMillis() - lastScanAt
lastScanAt = System.currentTimeMillis()
"onScanResult(delay=${delay}ms, callbackType=$callbackType, result=$result)"
}
trySend(filterResults(setOf(result)))
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
log(TAG, VERBOSE) {
val delay = System.currentTimeMillis() - lastScanAt
lastScanAt = System.currentTimeMillis()
"onBatchScanResults(delay=${delay}ms, results=$results)"
}
trySend(filterResults(results))
}
override fun onScanFailed(errorCode: Int) {
log(TAG, WARN) { "onScanFailed(errorCode=$errorCode)" }
}
}
val forwarderConsumer = if (disableDirectScanCallback) {
scanResultForwarder.results
.onEach { results -> trySend(filterResults(results)) }
.launchIn(this)
} else {
null
}
val flushJob = if (!disableDirectScanCallback) {
launch {
log(TAG) { "Flush job launched" }
while (isActive) {
log(TAG, VERBOSE) { "Flushing scan results." }
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
}
} else {
null
}
val filterList = when {
useOffloadedFiltering -> filters.toList()
else -> emptyList()
}
val scanSettings = ScanSettings.Builder().apply {
setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
when (scannerMode) {
ScannerMode.LOW_POWER -> {
setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
setMatchMode(ScanSettings.MATCH_MODE_STICKY)
setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
}
ScannerMode.BALANCED -> {
setScanMode(ScanSettings.SCAN_MODE_BALANCED)
setMatchMode(ScanSettings.MATCH_MODE_STICKY)
setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
}
ScannerMode.LOW_LATENCY -> {
setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
}
}
val delay = if (useOffloadedBatching) {
when (scannerMode) {
ScannerMode.LOW_POWER -> 2000L
ScannerMode.BALANCED -> 1000L
ScannerMode.LOW_LATENCY -> 500L
}
} else {
0L // Anything > 0 enables batching
}
setReportDelay(delay)
}.build()
if (disableDirectScanCallback) {
val callbackIntent = createStartIntent()
log(TAG) { "Intent callback: startScan(filters=$filters, settings=$scanSettings, callbackIntent=$callbackIntent)" }
scanner.startScan(filterList, scanSettings, callbackIntent)
} else {
log(TAG) { "Direct callback: startScan(filters=$filters, settings=$scanSettings, callback=$callback)" }
scanner.startScan(filterList, scanSettings, callback)
}
awaitClose {
forwarderConsumer?.cancel()
flushJob?.cancel()
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
}
log(TAG) { "BleScanner stopped" }
}
}
.map { fakeBleData.maybeAddfakeData(it) }
private val receiverIntent by lazy {
Intent(context, BleScanResultReceiver::class.java).apply {
action = BleScanResultReceiver.ACTION
}
}
private fun createStartIntent(): PendingIntent = PendingIntent.getBroadcast(
context,
CALLBACK_INTENT_REQUESTCODE,
receiverIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE
)
private fun createStopIntent(): PendingIntent = PendingIntent.getBroadcast(
context,
270,
receiverIntent,
PendingIntentCompat.FLAG_IMMUTABLE
)
companion object {
private const val CALLBACK_INTENT_REQUESTCODE = 270
private val TAG = logTag("Bluetooth", "BleScanner")
}
}
@@ -0,0 +1,15 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothDevice
import java.time.Instant
data class BluetoothDevice2(
internal val internal: BluetoothDevice,
val seenFirstAt: Instant,
) {
val address: BluetoothAddress
get() = internal.address
val name: String?
get() = internal.name
}
@@ -6,7 +6,6 @@ import android.bluetooth.le.ScanResult
import android.os.ParcelUuid
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
return uuids?.contains(uuid) ?: false
@@ -22,8 +21,6 @@ fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
fun ScanFilter.matchesSafe(scanResult: ScanResult): Boolean = try {
matches(scanResult)
} catch (e: NullPointerException) {
log(TAG) { "AOSP error: ${e.asLog()}" }
log { "AOSP error: ${e.asLog()}" }
false
}
private val TAG = logTag("Bluetooth", "Extensions")
}
@@ -0,0 +1,231 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.BluetoothLeScanner
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Handler
import android.os.HandlerThread
import android.os.ParcelUuid
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.IOException
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BluetoothManager2 @Inject constructor(
private val manager: BluetoothManager,
@ApplicationContext private val context: Context,
private val dispatcherProvider: DispatcherProvider,
) {
val adapter: BluetoothAdapter?
get() = manager.adapter
val scanner: BluetoothLeScanner?
get() = adapter?.bluetoothLeScanner
val isBluetoothEnabled: Flow<Boolean> = callbackFlow {
send(manager.adapter?.isEnabled ?: false)
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (BluetoothAdapter.ACTION_STATE_CHANGED != intent.action) {
log(TAG) { "Unknown BluetoothAdapter action: $intent" }
return
}
val value = when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) {
BluetoothAdapter.STATE_OFF -> false
BluetoothAdapter.STATE_ON -> true
else -> false
}
trySend(value)
}
}
context.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
awaitClose { context.unregisterReceiver(receiver) }
}
fun getBluetoothProfile(profile: Int = BluetoothProfile.HEADSET): Flow<BluetoothProfile2> = callbackFlow {
log(TAG, VERBOSE) { "getBluetoothProfile(profile=$profile)" }
var profileProxy: BluetoothProfile2? = null
manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
profileProxy = BluetoothProfile2(
profileType = profile,
profileProxy = proxy,
).also { trySend(it) }
}
override fun onServiceDisconnected(profile: Int) {
log(TAG, WARN) { "onServiceDisconnected(profile=$profile)" }
close(IOException("BluetoothProfile service disconnected (profile=$profile)"))
}
}, profile)
awaitClose {
log(TAG) { "Closing BluetoothProfile: $profileProxy" }
profileProxy?.let {
manager.adapter.closeProfileProxy(it.profileType, it.proxy)
}
}
}
private fun monitorDevicesForProfile(
profile: Int = BluetoothProfile.HEADSET
): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
callbackFlow {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
trySend(bluetoothProfile.connectedDevices)
val filter = IntentFilter().apply {
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
}
val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
start()
}
val handler = Handler(handlerThread.looper)
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" }
val action = intent.action
if (action == null) {
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
return
}
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
if (device == null) {
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
return
}
this@callbackFlow.launch {
val currentDevices = bluetoothProfile.connectedDevices
when (action) {
BluetoothDevice.ACTION_ACL_CONNECTED -> {
log(TAG) { "Adding $device to current devices $currentDevices" }
trySend(currentDevices.plus(device))
}
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
log(TAG) { "Removing $device from current devices $currentDevices" }
trySend(currentDevices.minus(device))
}
}
}
}
}
context.registerReceiver(receiver, filter, null, handler)
awaitClose {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
context.unregisterReceiver(receiver)
}
}
}
private val seenDevicesLock = Mutex()
private val seenDevicesCache = mutableMapOf<String, Instant>()
fun connectedDevices(
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
): Flow<List<BluetoothDevice2>> = isBluetoothEnabled
.flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
.map { devices ->
val currentAddresses = devices.map { it.address }
seenDevicesLock.withLock {
val cleanedCache = seenDevicesCache.filterKeys { currentAddresses.contains(it) }
seenDevicesCache.clear()
seenDevicesCache.putAll(cleanedCache)
}
devices
.filter { device -> featureFilter.any { feature -> device.hasFeature(feature) } }
.map { device ->
BluetoothDevice2(
internal = device,
seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: Instant.now().also {
seenDevicesCache[device.address] = it
}
}
)
}
}
fun bondedDevices(): Flow<Set<BluetoothDevice2>> = flow {
val rawDevices = adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable")
val wrappedDevices = rawDevices.map { device ->
BluetoothDevice2(
internal = device,
seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: Instant.now().also {
seenDevicesCache[device.address] = it
}
}
)
}.toSet()
emit(wrappedDevices)
}
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
connectMethod.invoke(bluetoothProfile.proxy, device.internal)
log(TAG) { "Nudged connection to $device" }
true
} catch (e: Exception) {
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
false
}
}.first()
companion object {
private val TAG = logTag("Bluetooth", "Manager2")
}
}
@@ -0,0 +1,100 @@
package eu.darken.capod.common.bluetooth
import dagger.Reusable
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.fromHex
import java.time.Instant
import javax.inject.Inject
import kotlin.random.Random
@Reusable
class FakeBleData @Inject constructor(
private val debugSettings: DebugSettings,
) {
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
if (!debugSettings.showFakeData.value) return originals
return originals + getFakeData()
}
fun getFakeData(): Collection<BleScanResult> {
val fakeDevices = mutableListOf<BleScanResult>()
// AirPods Gen1
BleScanResult(
receivedAt = Instant.now(),
address = "78:73:AF:B4:85:22",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
).run {
fakeDevices.add(this)
}
// AirPods Gen2
BleScanResult(
receivedAt = Instant.now(),
address = "78:73:FF:B4:85:5E",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
).run {
fakeDevices.add(this)
}
// AirPods Gen3
BleScanResult(
receivedAt = Instant.now(),
address = "4E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 200,
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".fromHex())
).run {
fakeDevices.add(this)
}
// AirPods Max
BleScanResult(
receivedAt = Instant.now(),
address = "7E:E5:C7:65:D2:B5",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 300,
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".fromHex())
).run {
fakeDevices.add(this)
}
// BeatsFlex
BleScanResult(
receivedAt = Instant.now(),
address = "5E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
).run {
fakeDevices.add(this)
}
// Tws i99999
BleScanResult(
receivedAt = Instant.now(),
address = "5E:9E:D1:29:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".fromHex())
).run {
fakeDevices.add(this)
}
// Unknown Device
BleScanResult(
receivedAt = Instant.now(),
address = "6E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 500,
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
).run {
fakeDevices.add(this)
}
return fakeDevices
}
}
@@ -0,0 +1,25 @@
package eu.darken.capod.common.bluetooth
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.common.R
@JsonClass(generateAdapter = false)
enum class ScannerMode(
val identifier: String,
@StringRes val labelRes: Int
) {
@Json(name = "scanner.mode.lowpower") LOW_POWER(
"scanner.mode.lowpower",
R.string.settings_scanner_mode_lowpower_label
),
@Json(name = "scanner.mode.balanced") BALANCED(
"scanner.mode.balanced",
R.string.settings_scanner_mode_balanced_label
),
@Json(name = "scanner.mode.lowlatency") LOW_LATENCY(
"scanner.mode.lowlatency",
R.string.settings_scanner_mode_lowlatency_label
),
}
@@ -0,0 +1,42 @@
package eu.darken.capod.common.dagger
import android.app.Application
import android.app.NotificationManager
import android.bluetooth.BluetoothManager
import android.content.Context
import android.media.AudioManager
import androidx.work.WorkManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class AndroidModule {
@Provides
@Singleton
fun context(app: Application): Context = app.applicationContext
@Provides
@Singleton
fun notificationManager(context: Context): NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@Provides
@Singleton
fun bluetoothManager(context: Context): BluetoothManager =
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
@Provides
@Singleton
fun workerManager(context: Context): WorkManager =
WorkManager.getInstance(context)
@Provides
@Singleton
fun audioManager(context: Context): AudioManager =
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
@@ -0,0 +1,25 @@
package eu.darken.capod.common.debug
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
object Bugs {
var reporter: AutomaticBugReporter? = null
fun report(
tag: String,
message: String,
exception: Throwable
) {
log(TAG, VERBOSE) { "Reporting $exception" }
log(tag, ERROR) { "$message\n${exception.asLog()}" }
reporter?.notify(exception) ?: run {
log(TAG, WARN) { "Bug tracking not initialized yet." }
}
}
private val TAG = logTag("Bugs")
}
@@ -0,0 +1,38 @@
package eu.darken.capod.common.debug
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.preferences.PreferenceStoreMapper
import eu.darken.capod.common.preferences.Settings
import eu.darken.capod.common.preferences.createFlowPreference
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DebugSettings @Inject constructor(
@ApplicationContext private val context: Context,
) : Settings() {
override val preferences: SharedPreferences = context.getSharedPreferences("settings_debug", Context.MODE_PRIVATE)
val isAutoReportingEnabled = preferences.createFlowPreference(
key = "debug.bugreport.automatic.enabled",
// Reporting is opt-out for gplay, and opt-in for github builds
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
)
val isDebugModeEnabled = preferences.createFlowPreference("debug.mode.enabled", false)
val showFakeData = preferences.createFlowPreference("debug.fakedata.enabled", false)
val showUnfiltered = preferences.createFlowPreference("debug.blescanner.unfiltered.enabled", false)
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
isDebugModeEnabled,
showFakeData,
showUnfiltered,
)
}
@@ -2,7 +2,6 @@ package eu.darken.capod.common.debug.logging
import android.annotation.SuppressLint
import android.util.Log
import eu.darken.capod.common.TimeSource
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -11,53 +10,35 @@ import java.time.Instant
@SuppressLint("LogNotTimber")
class FileLogger(
private val logFile: File,
private val timeSource: TimeSource,
) : Logging.Logger {
class FileLogger(private val logFile: File) : Logging.Logger {
private var logWriter: OutputStreamWriter? = null
/**
* A failure here belongs to the caller: swallowing it left an installed logger writing nowhere,
* so a recording looked like it had started and produced an empty log.
*/
@SuppressLint("SetWorldReadable")
@Synchronized
fun start() {
if (logWriter != null) return
logFile.parentFile!!.mkdirs()
// Whether THIS attempt created the file decides what a failure below may delete: a resumed
// session appends to a log file that already holds the previous recording, and failing to
// open it must not erase that.
val createdNow = logFile.createNewFile()
if (createdNow) {
if (logFile.createNewFile()) {
Log.i(TAG, "File logger writing to " + logFile.path)
}
if (logFile.setReadable(true, false)) {
Log.i(TAG, "Debug run log read permission set")
}
var writer: OutputStreamWriter? = null
try {
writer = OutputStreamWriter(FileOutputStream(logFile, true))
writer.write("=== BEGIN ===\n")
writer.write("Logfile: $logFile\n")
writer.flush()
logWriter = OutputStreamWriter(FileOutputStream(logFile, true))
logWriter!!.write("=== BEGIN ===\n")
logWriter!!.write("Logfile: $logFile\n")
logWriter!!.flush()
Log.i(TAG, "File logger started.")
} catch (e: IOException) {
Log.e(TAG, "File logger failed to start.", e)
try {
writer?.close()
} catch (ignore: IOException) {
}
if (createdNow) logFile.delete()
throw e
e.printStackTrace()
logFile.delete()
if (logWriter != null) logWriter!!.close()
}
// Published only once it is usable, so a failed attempt leaves nothing behind that would
// make a later start() a no-op.
logWriter = writer
Log.i(TAG, "File logger started.")
}
@Synchronized
@@ -76,7 +57,7 @@ class FileLogger(
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
logWriter?.let {
try {
it.write("${timeSource.now()} ${priority.shortLabel}/$tag: $message\n")
it.write("${Instant.ofEpochMilli(System.currentTimeMillis())} ${priority.shortLabel}/$tag: $message\n")
it.flush()
} catch (e: IOException) {
Log.e(TAG, "Failed to write log line.", e)
@@ -95,3 +76,4 @@ class FileLogger(
private val TAG = logTag("Debug", "FileLogger")
}
}
@@ -34,8 +34,6 @@ object Logging {
)
}
private val TAG = logTag("Logging")
private val internalLoggers = mutableListOf<Logger>()
val loggers: List<Logger>
@@ -48,11 +46,11 @@ object Logging {
fun install(logger: Logger) {
synchronized(internalLoggers) { internalLoggers.add(logger) }
log(TAG) { "Was installed $logger" }
log { "Was installed $logger" }
}
fun remove(logger: Logger) {
log(TAG) { "Removing: $logger" }
log { "Removing: $logger" }
synchronized(internalLoggers) { internalLoggers.remove(logger) }
}
@@ -63,27 +61,39 @@ object Logging {
message: String
) {
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
snapshot.forEach {
val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
if (isLoggable) {
runCatching {
it.log(
priority = priority,
tag = tag,
metaData = metaData,
message = message
)
}
snapshot
.filter { it.isLoggable(priority) }
.forEach {
it.log(
priority = priority,
tag = tag,
metaData = metaData,
message = message
)
}
}
}
fun clearAll() {
log(TAG) { "Clearing all loggers" }
log { "Clearing all loggers" }
synchronized(internalLoggers) { internalLoggers.clear() }
}
}
inline fun Any.log(
priority: Logging.Priority = Logging.Priority.DEBUG,
metaData: Map<String, Any>? = null,
message: () -> String,
) {
if (Logging.hasReceivers) {
Logging.logInternal(
tag = "CAP:${logTagViaCallSite()}",
priority = priority,
metaData = metaData,
message = message(),
)
}
}
inline fun log(
tag: String,
priority: Logging.Priority = Logging.Priority.DEBUG,
@@ -100,25 +110,23 @@ inline fun log(
}
}
fun Throwable.asLog(): String = runCatching {
fun Throwable.asLog(): String {
val stringWriter = StringWriter(256)
val printWriter = PrintWriter(stringWriter, false)
printStackTrace(printWriter)
printWriter.flush()
stringWriter.toString()
}.getOrElse { renderFailure ->
"${asLogSummary()}\n<stacktrace unavailable: ${renderFailure.asLogSummary()}>"
return stringWriter.toString()
}
fun Throwable.asLogSummary(): String {
val throwableClass = javaClass.name
val throwableMessage = safeMessage()
return if (throwableMessage.isNullOrBlank()) {
throwableClass
@PublishedApi
internal fun Any.logTagViaCallSite(): String {
val javaClass = this::class.java
val fullClassName = javaClass.name
val outerClassName = fullClassName.substringBefore('$')
val simplerOuterClassName = outerClassName.substringAfterLast('.')
return if (simplerOuterClassName.isEmpty()) {
fullClassName
} else {
"$throwableClass: $throwableMessage"
simplerOuterClassName.removeSuffix("Kt")
}
}
private fun Throwable.safeMessage(): String? = runCatching { message }.getOrNull()
@@ -0,0 +1,18 @@
package eu.darken.capod.common.error
import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder
fun Throwable.asErrorDialogBuilder(
context: Context
) = MaterialAlertDialogBuilder(context).apply {
val error = this@asErrorDialogBuilder
val localizedError = error.localized(context)
setTitle(localizedError.label)
setMessage(localizedError.description)
setPositiveButton(android.R.string.ok) { _, _ ->
}
}
@@ -1,8 +1,7 @@
package eu.darken.capod.common.error
import android.app.Activity
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.R
interface HasLocalizedError {
fun getLocalizedError(context: Context): LocalizedError
@@ -11,11 +10,7 @@ interface HasLocalizedError {
data class LocalizedError(
val throwable: Throwable,
val label: String,
val description: String,
val fixActionLabel: String? = null,
val fixAction: ((Activity) -> Unit)? = null,
/** Shown inline in the error dialog if the fix action fails, instead of a length-capped toast. */
val fixActionErrorMessage: String? = null,
val description: String
) {
fun asText() = "$label:\n$description"
}
@@ -24,19 +19,16 @@ fun Throwable.localized(c: Context): LocalizedError = when {
this is HasLocalizedError -> this.getLocalizedError(c)
localizedMessage != null -> LocalizedError(
throwable = this,
label = "${c.getString(R.string.general_error_label)}: ${errorTypeName()}",
label = "${c.getString(R.string.general_error_label)}: ${this::class.simpleName!!}",
description = localizedMessage ?: getStackTracePeek()
)
else -> LocalizedError(
throwable = this,
label = "${c.getString(R.string.general_error_label)}: ${errorTypeName()}",
label = "${c.getString(R.string.general_error_label)}: ${this::class.simpleName!!}",
description = getStackTracePeek()
)
}
// Anonymous throwable classes have no simpleName — never crash while rendering an error.
private fun Throwable.errorTypeName(): String = this::class.simpleName ?: "Error"
private fun Throwable.getStackTracePeek() = this.stackTraceToString()
.lines()
.filterIndexed { index, _ -> index > 1 }
@@ -5,20 +5,8 @@ import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
@@ -141,27 +129,17 @@ class DynamicStateFlow<T>(
*
* Any errors that occurred during [action] will be rethrown by this method.
*/
suspend fun updateBlocking(action: suspend T.() -> T): T = coroutineScope {
suspend fun updateBlocking(action: suspend T.() -> T): T {
val update: Update<T> = Update(onModify = action)
// Subscribe BEFORE emitting: UNDISPATCHED runs the awaiter synchronously up to its first
// suspension inside first's collect, so the collector is registered on the shared flow
// before the update can be processed. Emitting first is a lost wakeup: a fast producer
// plus a reactive collector (RecorderModule reacts to every state with its own update)
// can process our update AND a successor before we subscribe, displacing our State from
// the replay-1 cache — the await then never completes.
val awaiter = async(start = CoroutineStart.UNDISPATCHED) {
internalFlow.first { it.updatedBy == update }
}
updateActions.emit(update)
lTag?.let { log(it, VERBOSE) { "Waiting for update." } }
val ourUpdate = awaiter.await()
val ourUpdate = internalFlow.first { it.updatedBy == update }
lTag?.let { log(it, VERBOSE) { "Finished waiting, got $ourUpdate" } }
ourUpdate.error?.let { throw it }
ourUpdate.value
return ourUpdate.value
}
private data class Update<T>(
@@ -36,27 +36,6 @@ inline fun <T1, T2, T3, R> combine(
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
crossinline transform: suspend (T1, T2, T3, T4) -> R
): Flow<R> = kotlinx.coroutines.flow.combine(
flow,
flow2,
flow3,
flow4,
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, R> combine(
flow: Flow<T1>,
@@ -171,34 +150,6 @@ inline fun <T1, T2, T3, T4, T5, T6, T7, T8, R> combine(
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
flow8: Flow<T8>,
flow9: Flow<T9>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R
): Flow<R> = kotlinx.coroutines.flow.combine(
flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
args[6] as T7,
args[7] as T8,
args[8] as T9
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, R> combine(
flow: Flow<T1>,
@@ -8,22 +8,32 @@ import eu.darken.capod.common.error.hasCause
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.*
import kotlin.time.Duration
/**
* Create a stateful flow, with the initial value of null, but never emits a null value.
* Helper method to create a new flow without suspending and without initial value
* The flow collector will just wait for the first value
*/
fun <T : Any> Flow<T>.shareLatest(
tag: String? = null,
scope: CoroutineScope,
started: SharingStarted = SharingStarted.WhileSubscribed(replayExpirationMillis = 0)
) = this
.onStart { if (tag != null) log(tag) { "shareLatest(...) start" } }
.onEach { if (tag != null) log(tag) { "shareLatest(...) emission: $it" } }
.onCompletion { if (tag != null) log(tag) { "shareLatest(...) completed." } }
.catch {
if (tag != null) log(tag) { "shareLatest(...) catch(): ${it.asLog()}" }
throw it
}
.stateIn(
scope = scope,
started = started,
initialValue = null
)
.filterNotNull()
fun <T : Any?> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
scope = scope,
@@ -53,6 +63,7 @@ fun <T> Flow<T>.takeUntilAfter(predicate: suspend (T) -> Boolean) = transformWhi
fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String) = this
.onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } }
.onEach { log(tag, VERBOSE) { "${identifier()}.onEach(): $it" } }
.onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } }
.catch {
if (it.hasCause(CancellationException::class)) {
@@ -0,0 +1,58 @@
package eu.darken.capod.common.lists
import android.content.Context
import android.content.res.Resources
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.*
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import eu.darken.capod.common.getColorForAttr
abstract class BaseAdapter<T : BaseAdapter.VH> : RecyclerView.Adapter<T>() {
@CallSuper
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
return onCreateBaseVH(parent, viewType)
}
abstract fun onCreateBaseVH(parent: ViewGroup, viewType: Int): T
@CallSuper
final override fun onBindViewHolder(holder: T, position: Int) {
onBindBaseVH(holder, position, mutableListOf())
}
@CallSuper
final override fun onBindViewHolder(holder: T, position: Int, payloads: MutableList<Any>) {
onBindBaseVH(holder, position, payloads)
}
abstract fun onBindBaseVH(holder: T, position: Int, payloads: MutableList<Any> = mutableListOf())
abstract class VH(@LayoutRes layoutRes: Int, private val parent: ViewGroup) : RecyclerView.ViewHolder(
LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
) {
val context: Context
get() = parent.context
val resources: Resources
get() = context.resources
val layoutInflater: LayoutInflater
get() = LayoutInflater.from(context)
fun getColor(@ColorRes colorRes: Int): Int = ContextCompat.getColor(context, colorRes)
fun getColorForAttr(@AttrRes attrRes: Int): Int = context.getColorForAttr(attrRes)
fun getString(@StringRes stringRes: Int, vararg args: Any): String = context.getString(stringRes, *args)
fun getQuantityString(@PluralsRes pluralRes: Int, quantity: Int, vararg args: Any): String =
context.resources.getQuantityString(pluralRes, quantity, *args)
fun getQuantityString(@PluralsRes pluralRes: Int, quantity: Int): String =
context.resources.getQuantityString(pluralRes, quantity, quantity)
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.common.lists
import androidx.viewbinding.ViewBinding
interface BindableVH<ItemT, ViewBindingT : ViewBinding> {
val viewBinding: Lazy<ViewBindingT>
val onBindData: ViewBindingT.(item: ItemT, payloads: List<Any>) -> Unit
fun bind(item: ItemT, payloads: MutableList<Any> = mutableListOf()) = with(viewBinding.value) {
onBindData(item, payloads)
}
}
@Suppress("unused")
inline fun <reified ItemT, ViewBindingT : ViewBinding> BindableVH<ItemT, ViewBindingT>.binding(
payload: Boolean = true,
crossinline block: ViewBindingT.(ItemT) -> Unit,
): ViewBindingT.(item: ItemT, payloads: List<Any>) -> Unit = { item: ItemT, payloads: List<Any> ->
val newestItem = when (payload) {
true -> payloads.filterIsInstance<ItemT>().lastOrNull() ?: item
false -> item
}
block(newestItem)
}
@@ -0,0 +1,13 @@
package eu.darken.capod.common.lists
import androidx.recyclerview.widget.RecyclerView
interface DataAdapter<T> {
val data: MutableList<T>
}
fun <X, T> X.update(newData: List<T>?, notify: Boolean = true) where X : DataAdapter<T>, X : RecyclerView.Adapter<*> {
data.clear()
if (newData != null) data.addAll(newData)
if (notify) notifyDataSetChanged()
}
@@ -0,0 +1,3 @@
package eu.darken.capod.common.lists
interface ListItem
@@ -0,0 +1,13 @@
package eu.darken.capod.common.lists
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
fun RecyclerView.setupDefaults(adapter: RecyclerView.Adapter<*>? = null, dividers: Boolean = true) = apply {
layoutManager = LinearLayoutManager(context)
itemAnimator = DefaultItemAnimator()
if (dividers) addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
if (adapter != null) this.adapter = adapter
}
@@ -0,0 +1,43 @@
package eu.darken.capod.common.lists.differ
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.common.lists.modular.mods.StableIdMod
class AsyncDiffer<A, T : DifferItem> internal constructor(
adapter: A,
compareItem: (T, T) -> Boolean = { i1, i2 -> i1.stableId == i2.stableId },
compareItemContent: (T, T) -> Boolean = { i1, i2 -> i1 == i2 },
determinePayload: (T, T) -> Any? = { i1, i2 ->
when {
i1::class == i2::class -> i1.payloadProvider?.invoke(i1, i2)
else -> null
}
}
) where A : HasAsyncDiffer<T>, A : ModularAdapter<*> {
private val callback = object : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = compareItem(oldItem, newItem)
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = compareItemContent(oldItem, newItem)
override fun getChangePayload(oldItem: T, newItem: T): Any? = determinePayload(oldItem, newItem)
}
private val internalList = mutableListOf<T>()
private val listDiffer = AsyncListDiffer(adapter, callback)
val currentList: List<T>
get() = synchronized(internalList) { internalList }
init {
adapter.modules.add(0, StableIdMod(currentList))
}
fun submitUpdate(newData: List<T>) {
listDiffer.submitList(newData) {
synchronized(internalList) {
internalList.clear()
internalList.addAll(newData)
}
}
}
}
@@ -0,0 +1,15 @@
package eu.darken.capod.common.lists.differ
import androidx.recyclerview.widget.RecyclerView
import eu.darken.capod.common.lists.modular.ModularAdapter
fun <X, T> X.update(newData: List<T>?)
where X : HasAsyncDiffer<T>, X : RecyclerView.Adapter<*> {
asyncDiffer.submitUpdate(newData ?: emptyList())
}
fun <A, T : DifferItem> A.setupDiffer(): AsyncDiffer<A, T>
where A : HasAsyncDiffer<T>, A : ModularAdapter<*> =
AsyncDiffer(this)
@@ -0,0 +1,10 @@
package eu.darken.capod.common.lists.differ
import eu.darken.capod.common.lists.ListItem
interface DifferItem : ListItem {
val stableId: Long
val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)?
get() = null
}
@@ -0,0 +1,10 @@
package eu.darken.capod.common.lists.differ
interface HasAsyncDiffer<T : DifferItem> {
val data: List<T>
get() = asyncDiffer.currentList
val asyncDiffer: AsyncDiffer<*, T>
}
@@ -0,0 +1,95 @@
package eu.darken.capod.common.lists.modular
import android.view.ViewGroup
import androidx.annotation.CallSuper
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.RecyclerView
import eu.darken.capod.common.lists.BaseAdapter
abstract class ModularAdapter<VH : ModularAdapter.VH> : BaseAdapter<VH>() {
val modules = mutableListOf<Module>()
init {
modules.filterIsInstance<Module.Setup>().forEach { it.onAdapterReady(this) }
}
override fun getItemId(position: Int): Long {
modules.filterIsInstance<Module.ItemId>().forEach {
val id = it.getItemId(this, position)
if (id != null) return id
}
return super.getItemId(position)
}
@CallSuper
override fun getItemViewType(position: Int): Int {
modules.filterIsInstance<Module.Typing>().forEach {
val type = it.onGetItemType(this, position)
if (type != null) return type
}
return super.getItemViewType(position)
}
override fun onCreateBaseVH(parent: ViewGroup, viewType: Int): VH {
modules.filterIsInstance<Module.Creator<VH>>().forEach {
val vh = it.onCreateModularVH(this, parent, viewType)
if (vh != null) return vh
}
throw IllegalStateException("Couldn't create VH for type $viewType with $parent")
}
@CallSuper
override fun onBindBaseVH(holder: VH, position: Int, payloads: MutableList<Any>) {
modules.filterIsInstance<Module.Binder<VH>>().forEach {
it.onBindModularVH(this, holder, position, payloads)
it.onPostBind(this, holder, position)
}
}
@CallSuper
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
modules.filterIsInstance<Module.RecyclerViewLifecycle>().forEach { it.onAttachedToRecyclerView(recyclerView) }
super.onAttachedToRecyclerView(recyclerView)
}
@CallSuper
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
modules.filterIsInstance<Module.RecyclerViewLifecycle>().forEach { it.onDetachedFromRecyclerView(recyclerView) }
super.onDetachedFromRecyclerView(recyclerView)
}
abstract class VH(@LayoutRes layoutRes: Int, parent: ViewGroup) : BaseAdapter.VH(layoutRes, parent)
interface Module {
interface Setup {
fun onAdapterReady(adapter: ModularAdapter<*>)
}
interface Creator<T : VH> : Module {
fun onCreateModularVH(adapter: ModularAdapter<T>, parent: ViewGroup, viewType: Int): T?
}
interface Binder<T : VH> : Module {
fun onBindModularVH(adapter: ModularAdapter<T>, vh: T, pos: Int, payloads: MutableList<Any>) {
// NOOP
}
fun onPostBind(adapter: ModularAdapter<T>, vh: T, pos: Int) {
// NOOP
}
}
interface Typing : Module {
fun onGetItemType(adapter: ModularAdapter<*>, pos: Int): Int?
}
interface ItemId : Module {
fun getItemId(adapter: ModularAdapter<*>, position: Int): Long?
}
interface RecyclerViewLifecycle : Module {
fun onDetachedFromRecyclerView(recyclerView: RecyclerView)
fun onAttachedToRecyclerView(recyclerView: RecyclerView)
}
}
}
@@ -0,0 +1,12 @@
package eu.darken.capod.common.lists.modular.mods
import eu.darken.capod.common.lists.modular.ModularAdapter
class ClickMod<VHT : ModularAdapter.VH> constructor(
private val listener: (VHT, Int) -> Unit
) : ModularAdapter.Module.Binder<VHT> {
override fun onBindModularVH(adapter: ModularAdapter<VHT>, vh: VHT, pos: Int, payloads: MutableList<Any>) {
vh.itemView.setOnClickListener { listener.invoke(vh, pos) }
}
}

Some files were not shown because too many files have changed in this diff Show More