mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a48171541 | ||
|
|
9b6fd7aa57 | ||
|
|
5d7062171d |
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 |
@@ -1,3 +0,0 @@
|
||||
worktrees/
|
||||
tmp/
|
||||
scheduled_tasks.lock
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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/`
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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-
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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
|
||||
@@ -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 %}
|
||||
+218
@@ -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
|
||||
@@ -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)
|
||||
|
||||
[](https://github.com/d4rken-org/capod/releases/latest)
|
||||
[](https://shields.rbtlog.dev/eu.darken.capod)
|
||||
[](https://github.com/d4rken/capod/actions/workflows/code-checks.yml)
|
||||
[](https://crowdin.com/project/capod)
|
||||
[](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) |
|
||||
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [](https://play.google.com/apps/testing/eu.darken.capod) | |
|
||||
| [Github (Release)](https://github.com/d4rken-org/capod/releases) | )  |
|
||||
| [Github (Pre-Release)](https://github.com/d4rken-org/capod/releases) | )  |
|
||||
| [F-Droid](https://f-droid.org/en/packages/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/) |
|
||||
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [](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) | |
|
||||
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [](https://github.com/d4rken-org/capod/releases/latest) |
|
||||
| [F-Droid](https://f-droid.org/en/packages/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/) |
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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()
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
+1
@@ -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)
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
<application>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
+1
-2
@@ -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
|
||||
@@ -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>
|
||||
+2
-5
@@ -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)
|
||||
}
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
+3
-11
@@ -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")
|
||||
}
|
||||
}
|
||||
+11
-18
@@ -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(
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
+2
-5
@@ -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,
|
||||
)
|
||||
|
||||
}
|
||||
+13
-31
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+38
-30
@@ -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) { _, _ ->
|
||||
|
||||
}
|
||||
}
|
||||
+4
-12
@@ -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 }
|
||||
+4
-26
@@ -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>(
|
||||
-49
@@ -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>,
|
||||
+25
-14
@@ -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
Reference in New Issue
Block a user