mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 18:56:11 -04:00
Compare commits
3
Commits
main
..
v2.17.1-rc0
| 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"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0
|
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
|
||||||
with:
|
with:
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
@@ -14,7 +14,7 @@ runs:
|
|||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Cache Gradle Wrapper
|
- name: Cache Gradle Wrapper
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/wrapper
|
~/.gradle/wrapper
|
||||||
@@ -24,10 +24,19 @@ runs:
|
|||||||
${{ runner.os }}-gradle-wrapper-
|
${{ runner.os }}-gradle-wrapper-
|
||||||
|
|
||||||
- name: Cache Gradle Dependencies
|
- name: Cache Gradle Dependencies
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
|
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
|
||||||
restore-keys: |
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
paths-ignore:
|
|
||||||
- VERSION
|
|
||||||
- version.properties
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-vital:
|
lint-vital:
|
||||||
name: Lint vitals
|
name: Lint vitals
|
||||||
@@ -24,9 +18,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
@@ -44,9 +36,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
@@ -58,50 +48,14 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
variant: [ Debug ]
|
variant: [ Debug,Beta,Release ]
|
||||||
flavor: [ testFoss,testGplay ]
|
flavor: [ testFoss,testGplay ]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
- name: Test modules
|
- name: Test modules
|
||||||
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
|
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
|
|
||||||
@@ -4,22 +4,14 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths-ignore:
|
|
||||||
- VERSION
|
|
||||||
- version.properties
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validation:
|
validation:
|
||||||
name: "Validation"
|
name: "Validation"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||||
with:
|
- uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 #v4.3.1
|
||||||
persist-credentials: false
|
|
||||||
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
|
|
||||||
|
|||||||
@@ -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:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- '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:
|
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:
|
release-github:
|
||||||
needs: validate-tag
|
|
||||||
name: Create GitHub release
|
name: Create GitHub release
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
actions: write
|
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
environment: foss-production
|
environment: foss-production
|
||||||
steps:
|
steps:
|
||||||
@@ -76,16 +24,23 @@ jobs:
|
|||||||
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
|
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
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
|
- name: Assemble beta APK
|
||||||
if: contains(github.ref_name, '-beta')
|
if: contains(steps.tagger.outputs.tag, '-beta')
|
||||||
run: ./gradlew assembleFossBeta
|
run: ./gradlew assembleFossBeta
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ github.ref }}
|
VERSION: ${{ github.ref }}
|
||||||
@@ -94,7 +49,7 @@ jobs:
|
|||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Assemble production APK
|
- name: Assemble production APK
|
||||||
if: "!contains(github.ref_name, '-beta')"
|
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||||
run: ./gradlew assembleFossRelease
|
run: ./gradlew assembleFossRelease
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ github.ref }}
|
VERSION: ${{ github.ref }}
|
||||||
@@ -103,42 +58,35 @@ jobs:
|
|||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Create pre-release
|
- name: Create pre-release
|
||||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
if: contains(steps.tagger.outputs.tag, '-beta')
|
||||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
|
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ steps.tagger.outputs.tag }}
|
||||||
name: ${{ github.ref_name }}
|
name: ${{ steps.tagger.outputs.tag }}
|
||||||
generate_release_notes: true
|
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:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
|
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
|
||||||
with:
|
with:
|
||||||
prerelease: false
|
prerelease: false
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ steps.tagger.outputs.tag }}
|
||||||
name: ${{ github.ref_name }}
|
name: ${{ steps.tagger.outputs.tag }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: app/build/outputs/apk/foss/release/eu.darken.capod-*.apk
|
files: app/build/outputs/apk/foss/release/*.apk
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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:
|
release-gplay:
|
||||||
needs: validate-tag
|
|
||||||
name: Create Google Play release
|
name: Create Google Play release
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
environment: gplay-production
|
environment: gplay-production
|
||||||
env:
|
|
||||||
BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile
|
|
||||||
steps:
|
steps:
|
||||||
- name: Decode Keystore
|
- name: Decode Keystore
|
||||||
env:
|
env:
|
||||||
@@ -161,47 +109,55 @@ jobs:
|
|||||||
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
|
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
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
|
- name: Set up ruby env
|
||||||
uses: ruby/setup-ruby@4eb9f110bac952a8b68ecf92e3b5c7a987594ba6 #v1.292.0
|
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 #v1.229.0
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.3.6
|
ruby-version: 3.3.6
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
working-directory: fastlane
|
|
||||||
|
|
||||||
- name: Verify fastlane Bundler wiring
|
# - name: Assemble WearOS beta and upload to Google Play
|
||||||
run: bundle exec fastlane --version
|
# 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
|
- 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
|
run: bundle exec fastlane beta
|
||||||
env:
|
env:
|
||||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
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
|
- 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
|
run: bundle exec fastlane production
|
||||||
env:
|
env:
|
||||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
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
|
|
||||||
@@ -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
|
*.jks
|
||||||
.local/*
|
.local/*
|
||||||
/fastlane/report.xml
|
/fastlane/report.xml
|
||||||
/fastlane/Appfile
|
/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/
|
|
||||||
@@ -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)
|
# Companion App for AirPods (CAPod)
|
||||||
|
|
||||||
[](https://github.com/d4rken-org/capod/releases/latest)
|
[](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://github.com/d4rken/capod/actions/workflows/code-checks.yml)
|
||||||
[](https://crowdin.com/project/capod)
|
[](https://crowdin.com/project/capod)
|
||||||
[](https://github.com/d4rken-org/capod/edit/main/README.md#download)
|
[](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.
|
* Ear detection with automatic play/pause.
|
||||||
* Automatically connect phone & AirPods.
|
* Automatically connect phone & AirPods.
|
||||||
* Show popup when case is opened.
|
* Show popup when case is opened.
|
||||||
|
* Support for Wear OS
|
||||||
* Widgets
|
* Widgets
|
||||||
|
|
||||||
CAPod is ad-free. Some additional features require an in-app purchase.
|
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 1. Generation
|
||||||
* AirPods Pro 2. Generation
|
* AirPods Pro 2. Generation
|
||||||
* AirPods Pro 2. Generation (USB-C)
|
* AirPods Pro 2. Generation (USB-C)
|
||||||
* AirPods Pro 3. Generation
|
|
||||||
* AirPods Max
|
* AirPods Max
|
||||||
* AirPods Max (USB-C)
|
|
||||||
* AirPods Max 2. Generation
|
|
||||||
* Power Beats Pro
|
* Power Beats Pro
|
||||||
* Power Beats Pro 2
|
|
||||||
* Power Beats 3
|
* Power Beats 3
|
||||||
* Power Beats 4
|
* Power Beats 4
|
||||||
* Beats Solo 3
|
* Beats Solo 3
|
||||||
* Beats Solo Pro
|
|
||||||
* Beats Solo 4
|
|
||||||
* Beats Solo Buds
|
|
||||||
* Beats Studio 3
|
* Beats Studio 3
|
||||||
* Beats Studio Buds
|
|
||||||
* Beats Studio Buds+
|
|
||||||
* Beats Studio Pro
|
|
||||||
* Beats X
|
* Beats X
|
||||||
* Beats Flex
|
* Beats Flex
|
||||||
* Beats Fit Pro
|
* Beats Fit Pro
|
||||||
@@ -61,12 +51,11 @@ Currently supported models:
|
|||||||
|
|
||||||
| Source | Status |
|
| 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](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) | |
|
| [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 Releases](https://github.com/d4rken-org/capod/releases) | [](https://github.com/d4rken-org/capod/releases/latest) |
|
||||||
| [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](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/) |
|
||||||
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [](https://apt.izzysoft.de/packages/eu.darken.capod/) |
|
|
||||||
|
|
||||||
## Support the project
|
## Support the project
|
||||||
|
|
||||||
@@ -81,7 +70,8 @@ Currently supported models:
|
|||||||
|
|
||||||
## Screenshots
|
## 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
|
## Thanks to
|
||||||
|
|
||||||
@@ -95,7 +85,6 @@ Currently supported models:
|
|||||||
check it out.
|
check it out.
|
||||||
* [@kavishdevar](https://github.com/kavishdevar/librepods) and
|
* [@kavishdevar](https://github.com/kavishdevar/librepods) and
|
||||||
his [LibrePods project](https://github.com/kavishdevar/librepods) for sharing a lot of cool stuff.
|
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
|
## License
|
||||||
|
|
||||||
|
|||||||
+4
-6
@@ -1,9 +1,6 @@
|
|||||||
theme: minima
|
theme: minima
|
||||||
plugins:
|
plugins:
|
||||||
- jekyll-relative-links
|
- jekyll-relative-links
|
||||||
- jekyll-github-metadata
|
|
||||||
- jemoji
|
|
||||||
|
|
||||||
relative_links:
|
relative_links:
|
||||||
enabled: true
|
enabled: true
|
||||||
collections: true
|
collections: true
|
||||||
@@ -14,12 +11,13 @@ author: "by Matthias Urhahn"
|
|||||||
|
|
||||||
include:
|
include:
|
||||||
- PRIVACY_POLICY.md
|
- PRIVACY_POLICY.md
|
||||||
- README.md
|
|
||||||
- CHANGELOG.md
|
|
||||||
exclude:
|
exclude:
|
||||||
- buildSrc
|
- buildSrc
|
||||||
- gradle/wrapper
|
- gradle/wrapper
|
||||||
- fastlane
|
- fastlane
|
||||||
|
- Gemfile
|
||||||
|
- Gemfile.lock
|
||||||
- crowdin*
|
- crowdin*
|
||||||
- app
|
- 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.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
||||||
|
import eu.darken.capod.debug.autoreport.FossAutoReporting
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@InstallIn(SingletonComponent::class)
|
@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 eu.darken.capod.common.debug.logging.logTag
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class GplayAutoReporting @Inject constructor(
|
class GplayAutoReporting @Inject constructor(
|
||||||
@@ -22,7 +21,7 @@ class GplayAutoReporting @Inject constructor(
|
|||||||
) : AutomaticBugReporter {
|
) : AutomaticBugReporter {
|
||||||
|
|
||||||
override fun setup(application: Application) {
|
override fun setup(application: Application) {
|
||||||
val isEnabled = debugSettings.isAutoReportingEnabled.valueBlocking
|
val isEnabled = debugSettings.isAutoReportingEnabled.value
|
||||||
log(TAG) { "setup(): isEnabled=$isEnabled" }
|
log(TAG) { "setup(): isEnabled=$isEnabled" }
|
||||||
|
|
||||||
if (!isEnabled) return
|
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 android.content.BroadcastReceiver
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
|
|
||||||
fun BroadcastReceiver.PendingResult.finish2(): Boolean = try {
|
fun BroadcastReceiver.PendingResult.finish2(): Boolean = try {
|
||||||
finish()
|
finish()
|
||||||
true
|
true
|
||||||
} catch (e: IllegalStateException) {
|
} catch (e: IllegalStateException) {
|
||||||
log(TAG) { "BroadcastReceiver.PendingResult.finish() failed: $e" }
|
log { "BroadcastReceiver.PendingResult.finish() failed: $e" }
|
||||||
false
|
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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.*
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
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.asLog
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
|
|
||||||
@Reusable
|
@Reusable
|
||||||
class WebpageTool @Inject constructor(
|
class WebpageTool @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// Returns whether an activity was actually started, so callers that gate behaviour on the page
|
fun open(address: String) {
|
||||||
// having opened (e.g. the FOSS sponsor unlock heuristic) don't fire when no browser handled it.
|
|
||||||
fun open(address: String): Boolean {
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW, address.toUri()).apply {
|
val intent = Intent(Intent.ACTION_VIEW, address.toUri()).apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
return try {
|
try {
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, ERROR) { "Failed to launch: ${e.asLog()}" }
|
log(ERROR) { "Failed to launch: ${e.asLog()}" }
|
||||||
false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.bluetooth.le.ScanResult
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.core.util.forEach
|
import androidx.core.util.forEach
|
||||||
import eu.darken.capod.common.SystemTimeSource
|
import com.squareup.moshi.Json
|
||||||
import eu.darken.capod.common.TimeSource
|
import com.squareup.moshi.JsonClass
|
||||||
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
|
||||||
import eu.darken.capod.common.serialization.MapIntByteArrayBase64Serializer
|
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
@Serializable
|
@JsonClass(generateAdapter = true)
|
||||||
data class BleScanResult(
|
data class BleScanResult(
|
||||||
@SerialName("receivedAt") @Serializable(with = InstantEpochMillisSerializer::class) val receivedAt: Instant,
|
@Json(name = "receivedAt") val receivedAt: Instant,
|
||||||
@SerialName("address") val address: String,
|
@Json(name = "address") val address: String,
|
||||||
@SerialName("rssi") val rssi: Int,
|
@Json(name = "rssi") val rssi: Int,
|
||||||
@SerialName("generatedAtNanos") val generatedAtNanos: Long,
|
@Json(name = "generatedAtNanos") val generatedAtNanos: Long,
|
||||||
@SerialName("manufacturerSpecificData") @Serializable(with = MapIntByteArrayBase64Serializer::class) val manufacturerSpecificData: Map<Int, ByteArray>
|
@Json(name = "manufacturerSpecificData") val manufacturerSpecificData: Map<Int, ByteArray>
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
|
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
|
||||||
@@ -33,11 +29,8 @@ data class BleScanResult(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromScanResult(
|
fun fromScanResult(scanResult: ScanResult) = BleScanResult(
|
||||||
scanResult: ScanResult,
|
receivedAt = Instant.now(),
|
||||||
timeSource: TimeSource = SystemTimeSource,
|
|
||||||
) = BleScanResult(
|
|
||||||
receivedAt = timeSource.now(),
|
|
||||||
address = scanResult.device.address,
|
address = scanResult.device.address,
|
||||||
rssi = scanResult.rssi,
|
rssi = scanResult.rssi,
|
||||||
generatedAtNanos = scanResult.timestampNanos,
|
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
|
val results: Flow<Collection<ScanResult>> = forwarder
|
||||||
|
|
||||||
fun forward(scanResults: Collection<ScanResult>) {
|
fun forward(scanResults: Collection<ScanResult>) {
|
||||||
log(TAG, VERBOSE) { "forward(${scanResults.logSummary()})" }
|
log(TAG, VERBOSE) { "forward($scanResults)" }
|
||||||
val success = forwarder.tryEmit(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 {
|
companion object {
|
||||||
private val TAG = logTag("Bluetooth", "BleScanner", "Forwarder")
|
private val TAG = logTag("Bluetooth", "BleScanner", "Forwarder")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -22,7 +22,7 @@ class BleScanResultReceiver : BroadcastReceiver() {
|
|||||||
@Inject lateinit var scanResultForwarder: BleScanResultForwarder
|
@Inject lateinit var scanResultForwarder: BleScanResultForwarder
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
log(TAG, VERBOSE) { "onReceive(action=${intent.action})" }
|
log(TAG, VERBOSE) { "onReceive($context, $intent)" }
|
||||||
if (intent.action != ACTION) {
|
if (intent.action != ACTION) {
|
||||||
log(TAG, WARN) { "Unknown action: ${intent.action}" }
|
log(TAG, WARN) { "Unknown action: ${intent.action}" }
|
||||||
return
|
return
|
||||||
@@ -43,7 +43,7 @@ class BleScanResultReceiver : BroadcastReceiver() {
|
|||||||
log(TAG, VERBOSE) { "callbackType=$callbackType" }
|
log(TAG, VERBOSE) { "callbackType=$callbackType" }
|
||||||
|
|
||||||
val scanResults = intent.getParcelableArrayListExtra<ScanResult>(BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT)
|
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) {
|
if (scanResults == null) {
|
||||||
log(TAG) { "Scan results were empty!" }
|
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 android.os.ParcelUuid
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
|
|
||||||
fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
|
fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
|
||||||
return uuids?.contains(uuid) ?: false
|
return uuids?.contains(uuid) ?: false
|
||||||
@@ -22,8 +21,6 @@ fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
|
|||||||
fun ScanFilter.matchesSafe(scanResult: ScanResult): Boolean = try {
|
fun ScanFilter.matchesSafe(scanResult: ScanResult): Boolean = try {
|
||||||
matches(scanResult)
|
matches(scanResult)
|
||||||
} catch (e: NullPointerException) {
|
} catch (e: NullPointerException) {
|
||||||
log(TAG) { "AOSP error: ${e.asLog()}" }
|
log { "AOSP error: ${e.asLog()}" }
|
||||||
false
|
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.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import eu.darken.capod.common.TimeSource
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -11,53 +10,35 @@ import java.time.Instant
|
|||||||
|
|
||||||
|
|
||||||
@SuppressLint("LogNotTimber")
|
@SuppressLint("LogNotTimber")
|
||||||
class FileLogger(
|
class FileLogger(private val logFile: File) : Logging.Logger {
|
||||||
private val logFile: File,
|
|
||||||
private val timeSource: TimeSource,
|
|
||||||
) : Logging.Logger {
|
|
||||||
private var logWriter: OutputStreamWriter? = null
|
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")
|
@SuppressLint("SetWorldReadable")
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun start() {
|
fun start() {
|
||||||
if (logWriter != null) return
|
if (logWriter != null) return
|
||||||
|
|
||||||
logFile.parentFile!!.mkdirs()
|
logFile.parentFile!!.mkdirs()
|
||||||
// Whether THIS attempt created the file decides what a failure below may delete: a resumed
|
if (logFile.createNewFile()) {
|
||||||
// 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) {
|
|
||||||
Log.i(TAG, "File logger writing to " + logFile.path)
|
Log.i(TAG, "File logger writing to " + logFile.path)
|
||||||
}
|
}
|
||||||
if (logFile.setReadable(true, false)) {
|
if (logFile.setReadable(true, false)) {
|
||||||
Log.i(TAG, "Debug run log read permission set")
|
Log.i(TAG, "Debug run log read permission set")
|
||||||
}
|
}
|
||||||
|
|
||||||
var writer: OutputStreamWriter? = null
|
|
||||||
try {
|
try {
|
||||||
writer = OutputStreamWriter(FileOutputStream(logFile, true))
|
logWriter = OutputStreamWriter(FileOutputStream(logFile, true))
|
||||||
writer.write("=== BEGIN ===\n")
|
logWriter!!.write("=== BEGIN ===\n")
|
||||||
writer.write("Logfile: $logFile\n")
|
logWriter!!.write("Logfile: $logFile\n")
|
||||||
writer.flush()
|
logWriter!!.flush()
|
||||||
|
Log.i(TAG, "File logger started.")
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "File logger failed to start.", e)
|
e.printStackTrace()
|
||||||
try {
|
|
||||||
writer?.close()
|
logFile.delete()
|
||||||
} catch (ignore: IOException) {
|
if (logWriter != null) logWriter!!.close()
|
||||||
}
|
|
||||||
if (createdNow) logFile.delete()
|
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
@Synchronized
|
||||||
@@ -76,7 +57,7 @@ class FileLogger(
|
|||||||
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
|
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
|
||||||
logWriter?.let {
|
logWriter?.let {
|
||||||
try {
|
try {
|
||||||
it.write("${timeSource.now()} ${priority.shortLabel}/$tag: $message\n")
|
it.write("${Instant.ofEpochMilli(System.currentTimeMillis())} ${priority.shortLabel}/$tag: $message\n")
|
||||||
it.flush()
|
it.flush()
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Failed to write log line.", e)
|
Log.e(TAG, "Failed to write log line.", e)
|
||||||
@@ -95,3 +76,4 @@ class FileLogger(
|
|||||||
private val TAG = logTag("Debug", "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>()
|
private val internalLoggers = mutableListOf<Logger>()
|
||||||
|
|
||||||
val loggers: List<Logger>
|
val loggers: List<Logger>
|
||||||
@@ -48,11 +46,11 @@ object Logging {
|
|||||||
|
|
||||||
fun install(logger: Logger) {
|
fun install(logger: Logger) {
|
||||||
synchronized(internalLoggers) { internalLoggers.add(logger) }
|
synchronized(internalLoggers) { internalLoggers.add(logger) }
|
||||||
log(TAG) { "Was installed $logger" }
|
log { "Was installed $logger" }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(logger: Logger) {
|
fun remove(logger: Logger) {
|
||||||
log(TAG) { "Removing: $logger" }
|
log { "Removing: $logger" }
|
||||||
synchronized(internalLoggers) { internalLoggers.remove(logger) }
|
synchronized(internalLoggers) { internalLoggers.remove(logger) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,27 +61,39 @@ object Logging {
|
|||||||
message: String
|
message: String
|
||||||
) {
|
) {
|
||||||
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
||||||
snapshot.forEach {
|
snapshot
|
||||||
val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
|
.filter { it.isLoggable(priority) }
|
||||||
if (isLoggable) {
|
.forEach {
|
||||||
runCatching {
|
it.log(
|
||||||
it.log(
|
priority = priority,
|
||||||
priority = priority,
|
tag = tag,
|
||||||
tag = tag,
|
metaData = metaData,
|
||||||
metaData = metaData,
|
message = message
|
||||||
message = message
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearAll() {
|
fun clearAll() {
|
||||||
log(TAG) { "Clearing all loggers" }
|
log { "Clearing all loggers" }
|
||||||
synchronized(internalLoggers) { internalLoggers.clear() }
|
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(
|
inline fun log(
|
||||||
tag: String,
|
tag: String,
|
||||||
priority: Logging.Priority = Logging.Priority.DEBUG,
|
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 stringWriter = StringWriter(256)
|
||||||
val printWriter = PrintWriter(stringWriter, false)
|
val printWriter = PrintWriter(stringWriter, false)
|
||||||
printStackTrace(printWriter)
|
printStackTrace(printWriter)
|
||||||
printWriter.flush()
|
printWriter.flush()
|
||||||
stringWriter.toString()
|
return stringWriter.toString()
|
||||||
}.getOrElse { renderFailure ->
|
|
||||||
"${asLogSummary()}\n<stacktrace unavailable: ${renderFailure.asLogSummary()}>"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Throwable.asLogSummary(): String {
|
@PublishedApi
|
||||||
val throwableClass = javaClass.name
|
internal fun Any.logTagViaCallSite(): String {
|
||||||
val throwableMessage = safeMessage()
|
val javaClass = this::class.java
|
||||||
return if (throwableMessage.isNullOrBlank()) {
|
val fullClassName = javaClass.name
|
||||||
throwableClass
|
val outerClassName = fullClassName.substringBefore('$')
|
||||||
|
val simplerOuterClassName = outerClassName.substringAfterLast('.')
|
||||||
|
return if (simplerOuterClassName.isEmpty()) {
|
||||||
|
fullClassName
|
||||||
} else {
|
} 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
|
package eu.darken.capod.common.error
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.common.R
|
||||||
|
|
||||||
interface HasLocalizedError {
|
interface HasLocalizedError {
|
||||||
fun getLocalizedError(context: Context): LocalizedError
|
fun getLocalizedError(context: Context): LocalizedError
|
||||||
@@ -11,11 +10,7 @@ interface HasLocalizedError {
|
|||||||
data class LocalizedError(
|
data class LocalizedError(
|
||||||
val throwable: Throwable,
|
val throwable: Throwable,
|
||||||
val label: String,
|
val label: String,
|
||||||
val description: 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,
|
|
||||||
) {
|
) {
|
||||||
fun asText() = "$label:\n$description"
|
fun asText() = "$label:\n$description"
|
||||||
}
|
}
|
||||||
@@ -24,19 +19,16 @@ fun Throwable.localized(c: Context): LocalizedError = when {
|
|||||||
this is HasLocalizedError -> this.getLocalizedError(c)
|
this is HasLocalizedError -> this.getLocalizedError(c)
|
||||||
localizedMessage != null -> LocalizedError(
|
localizedMessage != null -> LocalizedError(
|
||||||
throwable = this,
|
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()
|
description = localizedMessage ?: getStackTracePeek()
|
||||||
)
|
)
|
||||||
else -> LocalizedError(
|
else -> LocalizedError(
|
||||||
throwable = this,
|
throwable = this,
|
||||||
label = "${c.getString(R.string.general_error_label)}: ${errorTypeName()}",
|
label = "${c.getString(R.string.general_error_label)}: ${this::class.simpleName!!}",
|
||||||
description = getStackTracePeek()
|
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()
|
private fun Throwable.getStackTracePeek() = this.stackTraceToString()
|
||||||
.lines()
|
.lines()
|
||||||
.filterIndexed { index, _ -> index > 1 }
|
.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 eu.darken.capod.common.debug.logging.log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineStart
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.flow.*
|
||||||
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.plus
|
import kotlinx.coroutines.plus
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@@ -141,27 +129,17 @@ class DynamicStateFlow<T>(
|
|||||||
*
|
*
|
||||||
* Any errors that occurred during [action] will be rethrown by this method.
|
* 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)
|
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)
|
updateActions.emit(update)
|
||||||
|
|
||||||
lTag?.let { log(it, VERBOSE) { "Waiting for 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" } }
|
lTag?.let { log(it, VERBOSE) { "Finished waiting, got $ourUpdate" } }
|
||||||
|
|
||||||
ourUpdate.error?.let { throw it }
|
ourUpdate.error?.let { throw it }
|
||||||
|
|
||||||
ourUpdate.value
|
return ourUpdate.value
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class Update<T>(
|
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")
|
@Suppress("UNCHECKED_CAST", "LongParameterList")
|
||||||
inline fun <T1, T2, T3, T4, T5, R> combine(
|
inline fun <T1, T2, T3, T4, T5, R> combine(
|
||||||
flow: Flow<T1>,
|
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")
|
@Suppress("UNCHECKED_CAST", "LongParameterList")
|
||||||
inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, R> combine(
|
inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, R> combine(
|
||||||
flow: Flow<T1>,
|
flow: Flow<T1>,
|
||||||
+25
-14
@@ -8,22 +8,32 @@ import eu.darken.capod.common.error.hasCause
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.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 kotlin.time.Duration
|
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(
|
fun <T : Any?> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
|
||||||
scope = scope,
|
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
|
fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String) = this
|
||||||
.onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } }
|
.onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } }
|
||||||
|
.onEach { log(tag, VERBOSE) { "${identifier()}.onEach(): $it" } }
|
||||||
.onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } }
|
.onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } }
|
||||||
.catch {
|
.catch {
|
||||||
if (it.hasCause(CancellationException::class)) {
|
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