mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
47
Commits
v5.1.0-rc0
...
v5.1.2-rc0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb104cbe61 | ||
|
|
97cbca7a6e | ||
|
|
02278dab17 | ||
|
|
2240bd7ae5 | ||
|
|
bc4096cb57 | ||
|
|
5a0ba1b0bd | ||
|
|
d04abdf80c | ||
|
|
0609352cc9 | ||
|
|
abde0b7d6a | ||
|
|
b952aff38d | ||
|
|
c8f5ceda34 | ||
|
|
e6dbd2d660 | ||
|
|
228d7d1f0c | ||
|
|
67fead3589 | ||
|
|
d0aab583d2 | ||
|
|
367c8d28ff | ||
|
|
b04e1fe628 | ||
|
|
ea3ef1ff3b | ||
|
|
ff8b9cf6ed | ||
|
|
96bc3f5df7 | ||
|
|
1891a6d73a | ||
|
|
698415237d | ||
|
|
7ef672a11f | ||
|
|
f67e8aa7d1 | ||
|
|
bcb4bdff08 | ||
|
|
70d9432b2b | ||
|
|
9d06839a1c | ||
|
|
97f294e5f7 | ||
|
|
a40599c1dc | ||
|
|
175aef9e2f | ||
|
|
36ae35918a | ||
|
|
60ad1943b6 | ||
|
|
5e727e907d | ||
|
|
5360f54ea8 | ||
|
|
486efe6e65 | ||
|
|
c2ff1f111e | ||
|
|
26b72726a7 | ||
|
|
f35fe968c6 | ||
|
|
498f040a57 | ||
|
|
59b3106c3b | ||
|
|
89e8b83be4 | ||
|
|
f0531796dd | ||
|
|
a701fdfba7 | ||
|
|
118eaa8d08 | ||
|
|
016914ec05 | ||
|
|
0aadec0b75 | ||
|
|
e6cf507937 |
+5
-8
@@ -4,10 +4,7 @@ Android app that detects and monitors AirPods via Bluetooth LE. Displays battery
|
||||
|
||||
## Project Structure
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `app/` | Main Android app (FOSS and Google Play flavors) |
|
||||
| `app-common/` | Shared code between phone and Wear OS apps |
|
||||
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
|
||||
|
||||
@@ -20,10 +17,10 @@ Quick build check: `./gradlew assembleFossDebug`
|
||||
|
||||
| Path | Contains |
|
||||
|------|----------|
|
||||
| `app/src/main/java/` | Main app source (activities, fragments, services) |
|
||||
| `app-common/src/main/java/` | Shared logic (monitor, bluetooth, models) |
|
||||
| `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-common/src/test/` | Unit tests |
|
||||
| `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 |
|
||||
@@ -31,7 +28,6 @@ Quick build check: `./gradlew assembleFossDebug`
|
||||
## Development Tips
|
||||
|
||||
- Use `assembleFossDebug` as the fastest build variant for iteration
|
||||
- Shared code goes in `app-common/`, app-specific code in `app/`
|
||||
- Follow existing patterns — the codebase uses MVVM + Hilt + Coroutines
|
||||
- Always use string resources for user-facing text (see localization rules)
|
||||
- Check `git log --oneline -20` for commit message style before committing
|
||||
@@ -47,3 +43,4 @@ Detailed guidelines are in `.claude/rules/`:
|
||||
- `pull-requests.md` — PR title and description conventions
|
||||
- `agent-instructions.md` — Sub-agent delegation and critical thinking
|
||||
- `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens
|
||||
- `release.md` — Release workflow (`Release prepare` dispatch), inputs, channel mapping, rollback
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
description: Architecture overview, module structure, key components, data flow, and dependencies
|
||||
globs:
|
||||
- "app/**/*.kt"
|
||||
- "app-common/**/*.kt"
|
||||
- "**/*.gradle.kts"
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
## Multi-Module Structure
|
||||
## Single-Module Structure
|
||||
|
||||
- **app/**: Main Android application with FOSS and Google Play flavors
|
||||
- **app-common/**: Shared code between main app and Wear OS app
|
||||
One Gradle module: `app/`. Source sets:
|
||||
|
||||
- `main` — shared code (Compose UI, services, monitor, bluetooth, AAP protocol, widgets)
|
||||
- `foss` / `gplay` — flavor-specific code (e.g. upgrade/billing implementations)
|
||||
- `debug` — debug-only code including screenshot content composables
|
||||
- `test` / `testFoss` / `testGplay` — unit tests
|
||||
- `screenshotTest` — Compose Preview Screenshot tests for Play Store assets
|
||||
|
||||
A previous `app-common/` module was merged into `app/` (commit `be8f4919`).
|
||||
|
||||
## Core Patterns
|
||||
|
||||
@@ -22,18 +28,62 @@ globs:
|
||||
|
||||
## Key Components
|
||||
|
||||
### PodMonitor System
|
||||
### Device Monitoring
|
||||
|
||||
- `PodMonitor`: Core service that detects and tracks AirPods via Bluetooth LE
|
||||
- `MonitorControl`: Manages MonitorService lifecycle
|
||||
- `MonitorService`: Foreground service that continuously scans for AirPods
|
||||
- `BluetoothEventReceiver`: Handles system Bluetooth events
|
||||
`monitor/core/` is split into two data-source siblings that `DeviceMonitor` merges:
|
||||
|
||||
- `monitor/core/ble/BlePodMonitor` — passive BLE scanning; reads Apple advertisement beacons (battery, case state, in-ear, etc.). Works for any pod in range; no pairing required
|
||||
- `monitor/core/aap/` — AAP connection lifecycle layer on top of `AapConnectionManager`:
|
||||
- `AapLifecycleManager` — starts/stops the AAP subsystem
|
||||
- `AapAutoConnect` — auto-opens AAP sessions for bonded/known devices
|
||||
- `AapKeyPersister`, `AapLearnedSettingsPersister` — persist session keys and learned pod settings across app restarts
|
||||
- `StemConfigSender`, `StemPressReaction`, `AncGestureResolver` — push config and react to stem/HID events
|
||||
- `monitor/core/cache/DeviceStateCache` — persisted last-known state so profiles still show data when a device is out of range
|
||||
- `DeviceMonitor` — singleton that `combine`s `BlePodMonitor.devices + AapConnectionManager.allStates + DeviceStateCache + profiles` into unified `PodDevice` objects. ViewModels observe `DeviceMonitor.devices`; they do **not** reach into `BlePodMonitor` or the AAP layer directly
|
||||
- `MonitorControl` / `MonitorService` — foreground service lifecycle holding the scan awake
|
||||
- `BluetoothEventReceiver`, `BootCompletedReceiver` — system triggers that wake the service
|
||||
|
||||
**BLE vs AAP — what each path gives you:**
|
||||
|
||||
| | BLE (advertisements) | AAP (L2CAP session) |
|
||||
|---|---|---|
|
||||
| Direction | Read-only, passive | Bidirectional commands + events |
|
||||
| Prerequisite | Bluetooth on | Bonded + `BLUETOOTH_CONNECT` + active L2CAP socket |
|
||||
| Data | Battery, case open, in-ear, pod model | Settings, ANC mode control, press controls, stem events, device info |
|
||||
| Availability | Any pod in range | Only your own paired pods |
|
||||
|
||||
### Reaction System
|
||||
|
||||
- `ReactionSettingsFragment`: Configuration for popup notifications
|
||||
- `ReactionsCard`: Compose UI for reaction settings, embedded in the device settings screen
|
||||
- `PopUpWindow`: Displays AirPods status when case is opened
|
||||
- `PopUpPodViewFactory`: Creates UI components for different pod models
|
||||
- `PopUpContent`: Compose pod rendering — model-specific UI branches inline, no factory class
|
||||
|
||||
### Widget System (Glance)
|
||||
|
||||
- `BatteryGlanceWidget`, `AncGlanceWidget`: Jetpack Glance-based home-screen widgets
|
||||
- `WidgetConfigurationActivity`: Configuration UI launched on widget placement
|
||||
- Lives under `app/src/main/java/eu/darken/capod/main/ui/widget/`
|
||||
|
||||
### Upgrade / Pro Features
|
||||
|
||||
- `UpgradeRepo` interface with two flavor implementations:
|
||||
- `UpgradeRepoGplay` — billing-client backed, includes grace-period handling for interrupted purchases
|
||||
- `UpgradeControlFoss` — cache/sponsor-backed; users are `isPro = false` until they call `upgrade()`, after which the pro flag is persisted via DataStore
|
||||
- FOSS is **not** "always pro" — it's opt-in via a local sponsor flow
|
||||
|
||||
### AAP (Apple Accessory Protocol) Stack
|
||||
|
||||
Three-layer structure under `pods/core/apple/aap/`:
|
||||
|
||||
- **`protocol/`** — pure data: `AapMessage`, `AapCommand`, `AapSetting`, `AapDeviceProfile`, `AapDeviceInfo`, `StemPressEvent`, `KeyExchangeResult`. Plus `DefaultAapDeviceProfile` and `Model.Features` capturing per-model capability
|
||||
- **`engine/`** — session state machine for one connection:
|
||||
- `AapConnection` — the L2CAP socket wrapper
|
||||
- `AapSessionEngine` — drives the session lifecycle; tested in `AapSessionEngineTest`
|
||||
- `AapInboundInterpreter` / `AapOutboundController` — decode incoming messages, encode outgoing
|
||||
- `AapSettingsCoordinator`, `AapAncController`, `HidTracker`, `AapDeviceInfoDiagnostics` — feature-specific coordinators that sit on top of the session
|
||||
- **`AapConnectionManager`** (singleton) — owns all open AAP sessions keyed by `BluetoothAddress`, uses `L2capSocketFactory` to create sockets. Consumers don't touch `AapConnection` directly — they call `sendCommand(...)` and observe `allStates`
|
||||
|
||||
The monitor-layer glue (`monitor/core/aap/`) described above wires this stack into the foreground service and persists its learned state.
|
||||
|
||||
### Common Utilities
|
||||
|
||||
@@ -54,31 +104,20 @@ globs:
|
||||
|
||||
## Data Flow
|
||||
|
||||
The app follows a unidirectional data flow:
|
||||
|
||||
1. `BluetoothEventReceiver` detects Bluetooth events
|
||||
2. `MonitorService` scans for AirPods beacon data
|
||||
3. `PodMonitor` processes and stores device information
|
||||
4. ViewModels observe monitor data via repositories
|
||||
5. UI components react to ViewModel state changes
|
||||
6. `ReactionSystem` triggers popups and notifications
|
||||
|
||||
## Bluetooth LE Implementation
|
||||
|
||||
The app uses Android's Bluetooth LE APIs to scan for Apple device advertisements. The core scanning logic is in `MonitorService` which runs as a foreground service.
|
||||
|
||||
## Multi-Platform Considerations
|
||||
|
||||
Code shared between phone and Wear OS apps is placed in `app-common`. When modifying shared functionality, ensure compatibility across both platforms.
|
||||
1. `BluetoothEventReceiver` / `BootCompletedReceiver` wake `MonitorService` (foreground)
|
||||
2. `MonitorService` keeps `BlePodMonitor` scanning (passive advertisements) and `AapLifecycleManager` running (active L2CAP sessions via `AapConnectionManager`)
|
||||
3. `DeviceMonitor` merges BLE + AAP + cached state + profiles into `PodDevice` objects
|
||||
4. ViewModels (`OverviewViewModel`, `DeviceSettingsViewModel`, `PressControlsViewModel`, widget view models) observe `DeviceMonitor.devices`; settings/command changes are sent back through `AapConnectionManager.sendCommand(...)`
|
||||
5. Reaction triggers (case-open popup, auto-play, notifications) and widget state updates react to the merged flow
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit Tests**: Located in `app-common/src/test/` for shared logic
|
||||
- **Test Flavors**: Separate test configurations for FOSS and Google Play variants
|
||||
- **Unit Tests**: `app/src/test/` (shared), `app/src/testFoss/`, `app/src/testGplay/` (flavor-specific — e.g. `UpgradeRepoGplayTest`, `FossUpgradeSerializationTest`)
|
||||
- **Screenshot Tests**: `app/src/screenshotTest/` — Compose Preview Screenshot Testing, powers the Play Store screenshot pipeline
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- **Hilt**: Dependency injection framework
|
||||
- **AndroidX Navigation**: Fragment navigation with SafeArgs
|
||||
- **Navigation**: Navigation3 (`addNavigation3()`) drives current Compose screen routing. Some legacy `androidx.navigation` helpers still exist (`NavDirectionsExtensions`, `ViewModel3`) — don't assume SafeArgs is fully gone
|
||||
- **kotlinx.serialization**: JSON serialization for configuration and caching
|
||||
- **Material Design**: UI components following Material Design guidelines
|
||||
- **Material Design 3**: Compose Material3 UI components
|
||||
|
||||
@@ -21,6 +21,7 @@ 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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ globs:
|
||||
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_bluetooth_enabled`)
|
||||
- **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
|
||||
|
||||
@@ -17,5 +17,7 @@ When adding new user-facing strings:
|
||||
|
||||
- `profiles_create_title` (screen title)
|
||||
- `profiles_name_label` (form field label)
|
||||
- `profiles_delete_confirmation` (dialog message)
|
||||
- `error_network_unavailable` (error message)
|
||||
- `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_*`).
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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.
|
||||
|
||||
## Dispatch
|
||||
|
||||
```bash
|
||||
# Plan only — no commit, no tag, no push.
|
||||
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true
|
||||
|
||||
# Real cut.
|
||||
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=false
|
||||
```
|
||||
|
||||
After `dry_run=false`: Job 1 computes + writes the summary, then Job 2 immediately commits/tags/pushes/dispatches (no env gate — cancel the run between Job 1 and Job 2 if the summary looks wrong; you have ~seconds). `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` | 10% |
|
||||
| `-rc*` (or anything else) | `assembleFossRelease` | full release | `production` | **`beta`** | 10% |
|
||||
|
||||
`lane :production` in `Fastfile` uploads to Play's **beta** track at 10% — 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_ID` — numeric ID of the `d4rken-org-releaser` GitHub App
|
||||
- `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 `gh workflow run release-tag.yml` fails (rare — Job 1's auth precheck should prevent it), the tag is public but no pipeline runs. Re-dispatch: `gh workflow run release-tag.yml --ref v<new> -f dry_run=false`.
|
||||
@@ -19,16 +19,17 @@ ScreenshotContent.kt (mock data + composables)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (8 screens) |
|
||||
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions that wire content to locale annotations |
|
||||
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (7 screens) |
|
||||
| `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 (auto-generated by batch script) |
|
||||
| `fastlane/generate_screenshots.sh` | Batched generation across 68 locales |
|
||||
| `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 |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Full run — all 68 locales, ~12 batches, ~7 minutes
|
||||
# Full run — iterates over ALL_LOCALES in batches of BATCH_SIZE.
|
||||
# The script prints "Locales: N | Batch size: B | Batches: ceil(N/B)" at startup.
|
||||
./fastlane/generate_screenshots.sh
|
||||
|
||||
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
|
||||
@@ -37,7 +38,7 @@ ScreenshotContent.kt (mock data + composables)
|
||||
# Copy into fastlane directories (run after generate)
|
||||
./fastlane/copy_screenshots.sh
|
||||
|
||||
# Clean copy (removes old screenshots first)
|
||||
# Clean copy (removes old screenshots first) — REQUIRED when screens are removed or renamed
|
||||
./fastlane/copy_screenshots.sh --clean
|
||||
```
|
||||
|
||||
@@ -49,6 +50,12 @@ ScreenshotContent.kt (mock data + composables)
|
||||
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
|
||||
5. Run the full pipeline: `generate_screenshots.sh` then `copy_screenshots.sh`
|
||||
|
||||
## Removing or Renaming a Screenshot
|
||||
|
||||
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
|
||||
2. Run `generate_screenshots.sh`
|
||||
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/*/images/phoneScreenshots/` and get uploaded to Play Store
|
||||
|
||||
## After UI Changes
|
||||
|
||||
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate:
|
||||
@@ -60,7 +67,7 @@ When modifying a screen that appears in screenshots (check `ScreenshotContent.kt
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Batch size defaults to 2 locales (16 renders) to avoid layoutlib memory leak (~10MB/image)
|
||||
- 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)
|
||||
|
||||
@@ -30,13 +30,4 @@ runs:
|
||||
~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-caches-
|
||||
|
||||
- name: Cache Android Global Build-Cache
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.android/build-cache
|
||||
key: ${{ runner.os }}-android-build-cache-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-android-build-cache-
|
||||
${{ runner.os }}-gradle-caches-
|
||||
@@ -78,4 +78,27 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate metadata lengths
|
||||
run: bash fastlane/check_metadata_length.sh
|
||||
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
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Deploy GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
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
|
||||
@@ -0,0 +1,247 @@
|
||||
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
|
||||
actions: read
|
||||
outputs:
|
||||
new_name: ${{ steps.plan.outputs.new_name }}
|
||||
new_code: ${{ steps.plan.outputs.new_code }}
|
||||
current_name: ${{ steps.plan.outputs.current_name }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
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: Verify gh auth and dispatch capability
|
||||
run: gh workflow view release-tag.yml > /dev/null
|
||||
|
||||
- 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:
|
||||
app-id: ${{ secrets.RELEASE_APP_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: Re-validate after approval wait
|
||||
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 during approval)
|
||||
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: Dispatch release-tag.yml
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh workflow run release-tag.yml --ref "v${NEW_NAME}" -f dry_run=false
|
||||
|
||||
- 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"
|
||||
@@ -14,11 +14,54 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate-tag:
|
||||
name: Validate tag
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check tag-name format
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${REF_NAME}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}-(rc|beta)[0-9]{1,2}$ ]]; then
|
||||
echo "Tag '${REF_NAME}' does not match v<M.m.p-(rc|beta)n>" >&2
|
||||
echo "Releases must be cut via the 'Release prepare' workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify version.properties matches tag
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Strip leading 'v' to get the bare version name.
|
||||
tag_name="${REF_NAME#v}"
|
||||
# bump.sh in check mode emits current_name=...
|
||||
parsed=$(./tools/release/bump.sh --mode=check)
|
||||
current_name=$(echo "$parsed" | grep -E '^current_name=' | cut -d= -f2)
|
||||
if [[ "$current_name" != "$tag_name" ]]; then
|
||||
echo "Tag '${REF_NAME}' does not match version.properties name '$current_name'" >&2
|
||||
echo "version.properties + VERSION must equal the tag — releases must be cut via 'Release prepare'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release-github:
|
||||
needs: validate-tag
|
||||
name: Create GitHub release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
runs-on: ubuntu-22.04
|
||||
environment: foss-production
|
||||
steps:
|
||||
@@ -61,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Create pre-release
|
||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ github.ref_name }}
|
||||
@@ -73,7 +116,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ github.ref_name }}
|
||||
@@ -83,10 +126,19 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger GitHub Pages deployment
|
||||
if: "!(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh workflow run pages.yml --ref main
|
||||
|
||||
release-gplay:
|
||||
needs: validate-tag
|
||||
name: Create Google Play release
|
||||
runs-on: ubuntu-22.04
|
||||
environment: gplay-production
|
||||
env:
|
||||
BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile
|
||||
steps:
|
||||
- name: Decode Keystore
|
||||
env:
|
||||
@@ -122,6 +174,10 @@ jobs:
|
||||
with:
|
||||
ruby-version: 3.3.6
|
||||
bundler-cache: true
|
||||
working-directory: fastlane
|
||||
|
||||
- name: Verify fastlane Bundler wiring
|
||||
run: bundle exec fastlane --version
|
||||
|
||||
- name: Assemble beta and upload to Google Play
|
||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
|
||||
+4
-1
@@ -18,4 +18,7 @@
|
||||
# Screenshot test reference images (ephemeral, regenerated on demand)
|
||||
app/src/screenshotTest*/reference/
|
||||
.codex
|
||||
protocol-research/
|
||||
protocol-research/
|
||||
_site/
|
||||
.jekyll-cache/
|
||||
vendor/bundle/
|
||||
@@ -20,9 +20,6 @@ exclude:
|
||||
- buildSrc
|
||||
- gradle/wrapper
|
||||
- fastlane
|
||||
- Gemfile
|
||||
- Gemfile.lock
|
||||
- crowdin*
|
||||
- app
|
||||
- app-common
|
||||
- CONTRIBUTING.md
|
||||
|
||||
@@ -118,6 +118,7 @@ internal fun DeviceSettingsReactionsContent() = PreviewWrapper {
|
||||
now = MOCK_NOW,
|
||||
isPro = true,
|
||||
isNudgeAvailable = true,
|
||||
isClassicallyConnected = true,
|
||||
),
|
||||
onNavigateUp = {},
|
||||
)
|
||||
|
||||
@@ -10,22 +10,26 @@ import eu.darken.capod.common.debug.logging.LogCatLogger
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
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.common.flow.throttleLatest
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.ui.widget.WidgetManager
|
||||
import eu.darken.capod.main.ui.widget.toWidgetKey
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
|
||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@@ -42,17 +46,20 @@ open class App : Application() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
|
||||
|
||||
var foregroundExceptionHandled = false
|
||||
val foregroundExceptionHandled = AtomicBoolean(false)
|
||||
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
if (throwable.isForegroundServiceTimingException() && !foregroundExceptionHandled) {
|
||||
foregroundExceptionHandled = true
|
||||
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
||||
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable)
|
||||
val isTimingExc = throwable.isForegroundServiceTimingException()
|
||||
val isMain = thread === Looper.getMainLooper().thread
|
||||
if (isTimingExc && isMain && foregroundExceptionHandled.compareAndSet(false, true)) {
|
||||
runCatching {
|
||||
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
||||
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable)
|
||||
}
|
||||
Looper.loop()
|
||||
return@setDefaultUncaughtExceptionHandler
|
||||
}
|
||||
log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" }
|
||||
runCatching { log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
|
||||
if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1)
|
||||
}
|
||||
|
||||
@@ -63,10 +70,10 @@ open class App : Application() {
|
||||
appScope.launch { widgetManager.refreshWidgets() }
|
||||
|
||||
deviceMonitor.devicesWithProfiles()
|
||||
.distinctUntilChanged()
|
||||
.distinctUntilChangedBy { devices -> devices.map { it.toWidgetKey() } }
|
||||
.throttleLatest(1000)
|
||||
.onEach {
|
||||
log(TAG) { "Main device changed, refreshing widgets." }
|
||||
log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
|
||||
widgetManager.refreshWidgets()
|
||||
}
|
||||
.launchIn(appScope)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AppForegroundState @Inject constructor(
|
||||
@AppScope appScope: CoroutineScope,
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
) {
|
||||
|
||||
val isForeground: StateFlow<Boolean> = callbackFlow {
|
||||
val lifecycle = runCatching { ProcessLifecycleOwner.get().lifecycle }
|
||||
.getOrElse {
|
||||
log(TAG, WARN) { "Failed to access process lifecycle, assuming background: ${it.asLog()}" }
|
||||
trySend(false)
|
||||
close()
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
trySend(safeCurrentForegroundState())
|
||||
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> trySend(true)
|
||||
Lifecycle.Event.ON_STOP -> trySend(false)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
runCatching { lifecycle.addObserver(observer) }
|
||||
.onFailure {
|
||||
log(TAG, WARN) { "Failed to observe process lifecycle, assuming background: ${it.asLog()}" }
|
||||
trySend(false)
|
||||
close()
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
runCatching { lifecycle.removeObserver(observer) }
|
||||
.onFailure { log(TAG, WARN) { "Failed to remove process lifecycle observer: ${it.asLog()}" } }
|
||||
}
|
||||
}
|
||||
.flowOn(dispatcherProvider.MainImmediate)
|
||||
.catch {
|
||||
log(TAG, WARN) { "Foreground state flow failed, assuming background: ${it.asLog()}" }
|
||||
emit(false)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { log(TAG) { "isForeground=$it" } }
|
||||
.stateIn(
|
||||
scope = appScope,
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L),
|
||||
initialValue = safeCurrentForegroundState(),
|
||||
)
|
||||
|
||||
private fun safeCurrentForegroundState(): Boolean = runCatching {
|
||||
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}.getOrElse {
|
||||
log(TAG, WARN) { "Failed to read process lifecycle state, assuming background: ${it.asLog()}" }
|
||||
false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("App", "ForegroundState")
|
||||
}
|
||||
}
|
||||
@@ -32,14 +32,25 @@ class MediaControl @Inject constructor(
|
||||
clearRecentCapPause()
|
||||
}
|
||||
|
||||
suspend fun sendPause() {
|
||||
/**
|
||||
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
|
||||
*
|
||||
* Returns `true` when a key event was actually dispatched (and the 15-second
|
||||
* [wasRecentlyPausedByCap] window was set), `false` when the call was a no-op because
|
||||
* nothing was playing. Callers that need to distinguish "we actually paused" from "there
|
||||
* was nothing to pause" — e.g. the sleep reaction, which gates its notification and
|
||||
* cooldown on a real pause — should branch on the return value rather than checking
|
||||
* [isPlaying] themselves to avoid a check-then-act race with the audio system.
|
||||
*/
|
||||
suspend fun sendPause(): Boolean {
|
||||
log(TAG, INFO) { "sendPause()" }
|
||||
if (!audioManager.isMusicActive) {
|
||||
log(TAG, INFO) { "Music is not playing, not sending pause" }
|
||||
return
|
||||
return false
|
||||
}
|
||||
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
|
||||
markRecentCapPause()
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun sendPlayPause() {
|
||||
|
||||
@@ -318,6 +318,74 @@ class BluetoothManager2 @Inject constructor(
|
||||
emit(wrappedDevices)
|
||||
}
|
||||
|
||||
@android.annotation.SuppressLint("MissingPermission")
|
||||
private fun queryBondedAddresses(): Set<BluetoothAddress> =
|
||||
adapter?.bondedDevices?.map { it.address }?.toSet() ?: emptySet()
|
||||
|
||||
val bondedDeviceAddresses: Flow<Set<BluetoothAddress>> = callbackFlow {
|
||||
fun sendBondedAddresses(): Boolean = try {
|
||||
trySend(queryBondedAddresses())
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Error querying bonded device addresses: $e" }
|
||||
trySend(emptySet())
|
||||
close(e)
|
||||
false
|
||||
}
|
||||
|
||||
if (!sendBondedAddresses()) return@callbackFlow
|
||||
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != BluetoothDevice.ACTION_BOND_STATE_CHANGED) return
|
||||
sendBondedAddresses()
|
||||
}
|
||||
}
|
||||
try {
|
||||
context.registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED))
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Failed to register bond-state receiver: $e" }
|
||||
close(e)
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
try {
|
||||
context.unregisterReceiver(receiver)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Error unregistering bond-state receiver: $e" }
|
||||
}
|
||||
}
|
||||
}
|
||||
.retryWhen { cause, attempt ->
|
||||
log(TAG, WARN) { "bondedDeviceAddresses Flow failed (attempt ${attempt + 1}): $cause" }
|
||||
when {
|
||||
cause is SecurityException -> {
|
||||
delay(3_000L)
|
||||
true
|
||||
}
|
||||
attempt < 3 -> {
|
||||
delay(1000 * (attempt + 1))
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.catch { e ->
|
||||
log(TAG, ERROR) { "bondedDeviceAddresses Flow failed after retries: $e" }
|
||||
emit(emptySet())
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.setupCommonEventHandlers(TAG) { "bondedDeviceAddresses" }
|
||||
.stateIn(
|
||||
scope = appScope + dispatcherProvider.IO,
|
||||
started = SharingStarted.WhileSubscribed(
|
||||
stopTimeoutMillis = 5_000L,
|
||||
replayExpirationMillis = 0L,
|
||||
),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
private var _isNudgeAvailable: Boolean = true
|
||||
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
|
||||
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class ScannerMode(
|
||||
val identifier: String,
|
||||
@StringRes val labelRes: Int
|
||||
) {
|
||||
@SerialName("scanner.mode.lowpower") LOW_POWER(
|
||||
"scanner.mode.lowpower",
|
||||
R.string.settings_scanner_mode_lowpower_label
|
||||
),
|
||||
@SerialName("scanner.mode.balanced") BALANCED(
|
||||
"scanner.mode.balanced",
|
||||
R.string.settings_scanner_mode_balanced_label
|
||||
),
|
||||
@SerialName("scanner.mode.lowlatency") LOW_LATENCY(
|
||||
"scanner.mode.lowlatency",
|
||||
R.string.settings_scanner_mode_lowlatency_label
|
||||
),
|
||||
}
|
||||
enum class ScannerMode {
|
||||
@SerialName("scanner.mode.lowpower") LOW_POWER,
|
||||
@SerialName("scanner.mode.balanced") BALANCED,
|
||||
@SerialName("scanner.mode.lowlatency") LOW_LATENCY,
|
||||
}
|
||||
|
||||
@@ -255,11 +255,42 @@ object MockPodDataProvider {
|
||||
aap = null,
|
||||
)
|
||||
|
||||
/** Dual pods matched to a profile that has no paired Bluetooth device selected. */
|
||||
fun dualPodMissingPairedDevice(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-missing-paired",
|
||||
ble = MockDualBlePodSnapshot(
|
||||
_model = PodModel.AIRPODS_PRO2,
|
||||
_label = "My AirPods Pro",
|
||||
batteryLeftPodPercent = 0.80f,
|
||||
batteryRightPodPercent = 0.45f,
|
||||
_batteryCasePercent = 0.60f,
|
||||
_isLeftPodCharging = true,
|
||||
leftPodIcon = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIcon = R.drawable.device_airpods_pro2_right,
|
||||
_caseIcon = R.drawable.device_airpods_pro2_case,
|
||||
),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
/** Single pod matched to a profile that has no paired Bluetooth device selected. */
|
||||
fun singlePodMissingPairedDevice(): PodDevice = PodDevice(
|
||||
profileId = "preview-single-missing-paired",
|
||||
ble = MockSingleBlePodSnapshot(
|
||||
_model = PodModel.AIRPODS_MAX,
|
||||
_label = "AirPods Max",
|
||||
batteryHeadsetPercent = 0.85f,
|
||||
_isBeingWorn = true,
|
||||
),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
/** Cached-only dual pod — device fully offline, showing last known state. */
|
||||
fun dualPodCachedOnly(): PodDevice = PodDevice(
|
||||
profileId = "preview-cached",
|
||||
ble = null,
|
||||
aap = null,
|
||||
profileAddress = "AA:BB:CC:DD:EE:FF",
|
||||
profileModel = PodModel.AIRPODS_PRO2,
|
||||
cached = CachedDeviceState(
|
||||
profileId = "preview-cached",
|
||||
model = PodModel.AIRPODS_PRO2,
|
||||
|
||||
@@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.scan
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
@@ -54,7 +53,6 @@ fun <T> Flow<T>.takeUntilAfter(predicate: suspend (T) -> Boolean) = transformWhi
|
||||
|
||||
fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String) = this
|
||||
.onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } }
|
||||
.onEach { log(tag, VERBOSE) { "${identifier()}.onEach(): $it" } }
|
||||
.onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } }
|
||||
.catch {
|
||||
if (it.hasCause(CancellationException::class)) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.datastore.createValue
|
||||
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
@@ -44,8 +43,6 @@ class GeneralSettings @Inject constructor(
|
||||
val keepConnectedNotificationAfterDisconnect =
|
||||
dataStore.createValue("core.monitor.notification.connected.keepafterdisconnected", false)
|
||||
|
||||
val scannerMode = dataStore.createValue("core.scanner.mode", ScannerMode.BALANCED, json, onErrorFallbackToDefault = true)
|
||||
|
||||
val oldMinimumSignalQuality = dataStore.createValue("core.signal.minimum", 0.20f)
|
||||
|
||||
val oldMainDeviceAddress = dataStore.createValue<BluetoothAddress?>(
|
||||
@@ -79,6 +76,8 @@ class GeneralSettings @Inject constructor(
|
||||
|
||||
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
|
||||
|
||||
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
|
||||
|
||||
val themeMode = dataStore.createValue(
|
||||
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
|
||||
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
|
||||
|
||||
+136
-111
@@ -25,6 +25,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -41,14 +42,17 @@ import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.settings.SettingsInfoBox
|
||||
import eu.darken.capod.common.settings.SettingsSection
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.DeviceDetailItem
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.ReactionsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.SoundCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.buildDeviceInfoDetailItems
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.rememberDeviceInfoDetailLabels
|
||||
import eu.darken.capod.main.ui.devicesettings.components.ConnectedDevicesList
|
||||
import eu.darken.capod.main.ui.devicesettings.components.EqBarsChart
|
||||
import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDialog
|
||||
@@ -61,6 +65,10 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@Composable
|
||||
fun DeviceSettingsScreenHost(
|
||||
@@ -78,6 +86,19 @@ fun DeviceSettingsScreenHost(
|
||||
var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
|
||||
val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_rejected_message)
|
||||
val pendingInfoMessage = stringResource(R.string.device_settings_pending_info)
|
||||
|
||||
val hasPendingSettings = state?.device?.hasPendingSettings
|
||||
var lastPendingState by remember { mutableStateOf<Boolean?>(null) }
|
||||
LaunchedEffect(hasPendingSettings) {
|
||||
val current = hasPendingSettings ?: return@LaunchedEffect
|
||||
val previous = lastPendingState
|
||||
lastPendingState = current
|
||||
if (previous == false && current) {
|
||||
snackbarHostState.showSnackbar(pendingInfoMessage)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
vm.events.collect { event ->
|
||||
@@ -99,6 +120,10 @@ fun DeviceSettingsScreenHost(
|
||||
DeviceSettingsViewModel.Event.OffModeRejectedByDevice -> {
|
||||
snackbarHostState.showSnackbar(offRejectedMessage)
|
||||
}
|
||||
|
||||
DeviceSettingsViewModel.Event.DynamicEndOfChargeRejectedByDevice -> {
|
||||
snackbarHostState.showSnackbar(chargeCapRejectedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,8 +158,10 @@ fun DeviceSettingsScreenHost(
|
||||
onListeningModeCycleChange = { vm.setListeningModeCycle(it) },
|
||||
onAllowOffOptionChange = { vm.setAllowOffOption(it) },
|
||||
onSleepDetectionChange = { vm.setSleepDetection(it) },
|
||||
onDynamicEndOfChargeChange = { vm.setDynamicEndOfCharge(it) },
|
||||
onDeviceNameChange = { vm.setDeviceName(it) },
|
||||
onPressControlsClick = { vm.navToPressControls() },
|
||||
onEditProfile = { vm.navToEditProfile() },
|
||||
onForceConnect = { vm.forceConnect() },
|
||||
onUpgrade = { vm.launchUpgrade() },
|
||||
onOnePodModeChange = { vm.setOnePodMode(it) },
|
||||
@@ -146,6 +173,7 @@ fun DeviceSettingsScreenHost(
|
||||
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
|
||||
onFixMonitorMode = { vm.setMonitorModeAutomatic() },
|
||||
onOpenIssueTracker = { vm.openIssueTracker() },
|
||||
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -168,8 +196,10 @@ fun DeviceSettingsScreen(
|
||||
onListeningModeCycleChange: (Int) -> Unit = {},
|
||||
onAllowOffOptionChange: (Boolean) -> Unit = {},
|
||||
onSleepDetectionChange: (Boolean) -> Unit = {},
|
||||
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
|
||||
onDeviceNameChange: (String) -> Unit = {},
|
||||
onPressControlsClick: () -> Unit = {},
|
||||
onEditProfile: () -> Unit = {},
|
||||
onForceConnect: () -> Unit = {},
|
||||
onUpgrade: () -> Unit = {},
|
||||
onOnePodModeChange: (Boolean) -> Unit = {},
|
||||
@@ -181,6 +211,7 @@ fun DeviceSettingsScreen(
|
||||
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
||||
onFixMonitorMode: () -> Unit = {},
|
||||
onOpenIssueTracker: () -> Unit = {},
|
||||
onOpenAapTracker: () -> Unit = {},
|
||||
) {
|
||||
val device = state.device
|
||||
val features = device?.model?.features
|
||||
@@ -237,86 +268,18 @@ fun DeviceSettingsScreen(
|
||||
device.firstSeenFormatted(state.now)
|
||||
} else null
|
||||
val info = device.deviceInfo
|
||||
val detailItems = buildList<DeviceDetailItem> {
|
||||
if (info != null) {
|
||||
if (info.manufacturer.isNotBlank()) {
|
||||
add(
|
||||
DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_manufacturer_label),
|
||||
info.manufacturer
|
||||
)
|
||||
)
|
||||
}
|
||||
if (info.serialNumber.isNotBlank()) {
|
||||
add(
|
||||
DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_serial_label),
|
||||
info.serialNumber
|
||||
)
|
||||
)
|
||||
}
|
||||
val hasFirmware = info.firmwareVersion.isNotBlank()
|
||||
val hasBuild = !info.buildNumber.isNullOrBlank()
|
||||
if (hasFirmware && hasBuild) {
|
||||
add(
|
||||
DeviceDetailItem.Paired(
|
||||
start = DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_firmware_label),
|
||||
info.firmwareVersion
|
||||
),
|
||||
end = DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_build_label),
|
||||
info.buildNumber!!
|
||||
),
|
||||
)
|
||||
)
|
||||
} else if (hasFirmware) {
|
||||
add(
|
||||
DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_firmware_label),
|
||||
info.firmwareVersion
|
||||
)
|
||||
)
|
||||
} else if (hasBuild) {
|
||||
add(
|
||||
DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_build_label),
|
||||
info.buildNumber!!
|
||||
)
|
||||
)
|
||||
}
|
||||
val hasLeft = !info.leftEarbudSerial.isNullOrBlank()
|
||||
val hasRight = !info.rightEarbudSerial.isNullOrBlank()
|
||||
if (hasLeft && hasRight) {
|
||||
add(
|
||||
DeviceDetailItem.Paired(
|
||||
start = DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_left_serial_label),
|
||||
info.leftEarbudSerial!!
|
||||
),
|
||||
end = DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_right_serial_label),
|
||||
info.rightEarbudSerial!!
|
||||
),
|
||||
)
|
||||
)
|
||||
} else if (hasLeft) {
|
||||
add(
|
||||
DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_left_serial_label),
|
||||
info.leftEarbudSerial!!
|
||||
)
|
||||
)
|
||||
} else if (hasRight) {
|
||||
add(
|
||||
DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_right_serial_label),
|
||||
info.rightEarbudSerial!!
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
val locale = LocalConfiguration.current.locales[0]
|
||||
val zoneId = ZoneId.systemDefault()
|
||||
val dateFormatter = remember(locale, zoneId) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(locale)
|
||||
.withZone(zoneId)
|
||||
}
|
||||
val detailItems = buildDeviceInfoDetailItems(
|
||||
info = info,
|
||||
labels = rememberDeviceInfoDetailLabels(),
|
||||
formatDate = { instant -> dateFormatter.format(instant) },
|
||||
)
|
||||
DeviceInfoCard(
|
||||
deviceInfo = device.deviceInfo,
|
||||
modelLabel = buildModelLabel(device),
|
||||
@@ -331,27 +294,46 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Not connected info — BLE live but no AAP connection
|
||||
if (device != null && device.ble != null && !device.isAapConnected && device.address != null) {
|
||||
if (state.isClassicallyConnected) {
|
||||
// Device is connected for audio but AAP isn't available — show passive info
|
||||
item("aap_unavailable_info") {
|
||||
AapUnavailableCard()
|
||||
}
|
||||
} else {
|
||||
// Device is nearby but not connected — prompt user to connect
|
||||
item("not_connected_info") {
|
||||
NotConnectedCard(
|
||||
isNudgeAvailable = state.isNudgeAvailable,
|
||||
isForceConnecting = state.isForceConnecting,
|
||||
onConnect = onForceConnect,
|
||||
)
|
||||
}
|
||||
// Profile has no paired Bluetooth device — supersedes all other state cards
|
||||
if (device != null && !device.hasSelectedPairedDevice) {
|
||||
item("missing_paired_device") {
|
||||
MissingPairedDeviceBanner(
|
||||
onClick = onEditProfile,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reactions (per-profile, not gated on AAP) ─────────────────
|
||||
if (device != null && features != null) {
|
||||
// Not nearby — no live BLE; settings require the device to be present
|
||||
if (device != null && device.hasSelectedPairedDevice &&
|
||||
device.ble == null && !state.isClassicallyConnected
|
||||
) {
|
||||
item("not_nearby_info") {
|
||||
SettingsInfoBox(
|
||||
title = stringResource(R.string.device_settings_not_nearby_label),
|
||||
text = stringResource(R.string.device_settings_not_nearby_description),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Not connected info — device is nearby but not connected; prompt user to connect
|
||||
if (device != null && device.hasSelectedPairedDevice &&
|
||||
device.ble != null && !device.isAapConnected && device.address != null &&
|
||||
!state.isClassicallyConnected
|
||||
) {
|
||||
item("not_connected_info") {
|
||||
NotConnectedCard(
|
||||
isNudgeAvailable = state.isNudgeAvailable,
|
||||
isForceConnecting = state.isForceConnecting,
|
||||
onConnect = onForceConnect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reactions (gated on classic connection — needs phone to be the audio target) ──
|
||||
if (device != null && device.hasSelectedPairedDevice &&
|
||||
features != null && state.isClassicallyConnected
|
||||
) {
|
||||
item("reactions_section") {
|
||||
ReactionsCard(
|
||||
device = device,
|
||||
@@ -374,15 +356,7 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
|
||||
// Settings — only show when AAP is connected
|
||||
if (features != null && device.isAapConnected) {
|
||||
|
||||
if (device.hasPendingSettings == true) {
|
||||
item("pending_info") {
|
||||
SettingsInfoBox(
|
||||
text = stringResource(R.string.device_settings_pending_info),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (features != null && device.isAapConnected && device.hasSelectedPairedDevice) {
|
||||
|
||||
// ── Noise Control ────────────────────────────
|
||||
if (features.hasAncControl && device.ancMode != null) {
|
||||
@@ -449,6 +423,18 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Battery ──────────────────────────────────
|
||||
if (features.hasDynamicEndOfCharge && device.dynamicEndOfCharge != null) {
|
||||
item("battery_section") {
|
||||
BatteryCard(
|
||||
device = device,
|
||||
features = features,
|
||||
enabled = enabled,
|
||||
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connections ───────────────────────────────
|
||||
val connectedDevices = device.connectedDevices
|
||||
if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) {
|
||||
@@ -462,13 +448,22 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// EQ visualization (debug only)
|
||||
val eqBands = device.eqBands
|
||||
if (eu.darken.capod.BuildConfig.DEBUG && eqBands != null && eqBands.sets.isNotEmpty()) {
|
||||
// PME Config visualization (debug only, opcode 0x53).
|
||||
// PME = Personal Medical Equipment (hearing-aid band gains for iOS
|
||||
// 18.1+ AirPods Pro 2 hearing-aid mode). The bar chart reuses the
|
||||
// EQ visualization because the data shape (per-ear × per-profile ×
|
||||
// band gains) renders identically; hidden on all-zero payloads, which
|
||||
// just mean the user hasn't configured a hearing-aid profile yet.
|
||||
val pmeConfig = device.pmeConfig
|
||||
if (eu.darken.capod.BuildConfig.DEBUG &&
|
||||
pmeConfig != null &&
|
||||
pmeConfig.sets.isNotEmpty() &&
|
||||
!pmeConfig.isAllZero
|
||||
) {
|
||||
item("eq_section") {
|
||||
SettingsSection(title = stringResource(R.string.device_settings_eq_label)) {
|
||||
EqBarsChart(
|
||||
sets = eqBands.sets,
|
||||
sets = pmeConfig.sets,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
@@ -476,6 +471,16 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced settings unavailable — phone's Bluetooth lacks AAP support; passive info, shown last
|
||||
if (device != null && device.hasSelectedPairedDevice &&
|
||||
device.ble != null && !device.isAapConnected && device.address != null &&
|
||||
state.isClassicallyConnected
|
||||
) {
|
||||
item("aap_unavailable_info") {
|
||||
AapUnavailableCard(onOpenTracker = onOpenAapTracker)
|
||||
}
|
||||
}
|
||||
|
||||
item("bottom_spacer") {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
@@ -496,6 +501,12 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
|
||||
manufacturer = "Apple Inc.",
|
||||
serialNumber = "W5J7KV0N04",
|
||||
firmwareVersion = "7A305",
|
||||
hardwareVersion = "1.0.0",
|
||||
leftEarbudSerial = "H3KL7HR926JY",
|
||||
rightEarbudSerial = "H3KL2AYL26K0",
|
||||
marketingVersion = "8454624",
|
||||
leftEarbudFirstPaired = Instant.ofEpochSecond(1697480211L),
|
||||
rightEarbudFirstPaired = Instant.ofEpochSecond(1697480211L),
|
||||
),
|
||||
settings = mapOf(
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(
|
||||
@@ -520,11 +531,13 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
|
||||
muteMic = AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS,
|
||||
endCall = AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS,
|
||||
),
|
||||
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
now = MOCK_NOW,
|
||||
isPro = isPro,
|
||||
isClassicallyConnected = true,
|
||||
)
|
||||
|
||||
@Preview2
|
||||
@@ -590,3 +603,15 @@ private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper {
|
||||
onNavigateUp = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceSettingsMissingPairedDevicePreview() = PreviewWrapper {
|
||||
DeviceSettingsScreen(
|
||||
state = DeviceSettingsViewModel.State(
|
||||
device = MockPodDataProvider.dualPodMissingPairedDevice(),
|
||||
now = MOCK_NOW,
|
||||
),
|
||||
onNavigateUp = {},
|
||||
)
|
||||
}
|
||||
|
||||
+36
-1
@@ -79,6 +79,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
data class SendFailed(val command: AapCommand, val message: String?) : Event
|
||||
data object SystemRenameUnavailable : Event
|
||||
data object OffModeRejectedByDevice : Event
|
||||
data object DynamicEndOfChargeRejectedByDevice : Event
|
||||
}
|
||||
|
||||
val events = SingleEventFlow<Event>()
|
||||
@@ -91,6 +92,16 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
aapManager.settingRejectedEvents.collect { (address, command) ->
|
||||
if (address != currentAddress()) return@collect
|
||||
when (command) {
|
||||
is AapCommand.SetDynamicEndOfCharge ->
|
||||
events.tryEmit(Event.DynamicEndOfChargeRejectedByDevice)
|
||||
else -> Unit // Other rejected commands handled elsewhere (e.g. ANC OFF)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val state = targetProfileId.flatMapLatest { profileId ->
|
||||
@@ -283,7 +294,16 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setSleepDetection(enabled: Boolean) = send(AapCommand.SetSleepDetection(enabled))
|
||||
fun setSleepDetection(enabled: Boolean) = launch {
|
||||
log(TAG, INFO) { "setSleepDetection($enabled)" }
|
||||
if (enabled && !upgradeRepo.isPro()) {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
return@launch
|
||||
}
|
||||
sendInternal(AapCommand.SetSleepDetection(enabled))
|
||||
}
|
||||
|
||||
fun setDynamicEndOfCharge(enabled: Boolean) = send(AapCommand.SetDynamicEndOfCharge(enabled))
|
||||
|
||||
fun setDeviceName(name: String) = launch {
|
||||
val address = currentAddress() ?: return@launch
|
||||
@@ -419,9 +439,20 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
fun navToPressControls() = launch {
|
||||
log(TAG, INFO) { "navToPressControls()" }
|
||||
val profileId = targetProfileId.value ?: return@launch
|
||||
val device = deviceMonitor.getDeviceForProfile(profileId)
|
||||
if (device?.isAapReady != true) {
|
||||
log(TAG, INFO) { "navToPressControls(): aborted, device not AAP-ready" }
|
||||
return@launch
|
||||
}
|
||||
navTo(Nav.Main.PressControls(profileId = profileId))
|
||||
}
|
||||
|
||||
fun navToEditProfile() = launch {
|
||||
log(TAG, INFO) { "navToEditProfile()" }
|
||||
val profileId = targetProfileId.value ?: return@launch
|
||||
navTo(Nav.Main.DeviceProfileCreation(profileId = profileId))
|
||||
}
|
||||
|
||||
fun launchUpgrade() {
|
||||
log(TAG, INFO) { "launchUpgrade()" }
|
||||
navTo(Nav.Main.Upgrade)
|
||||
@@ -431,6 +462,10 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
webpageTool.open("https://github.com/d4rken-org/capod/issues")
|
||||
}
|
||||
|
||||
fun openAapCompatibilityTracker() {
|
||||
webpageTool.open("https://github.com/d4rken-org/capod/issues/538")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("DeviceSettings", "VM")
|
||||
private const val OFF_BIT = 0x01
|
||||
|
||||
+12
-1
@@ -9,7 +9,9 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -18,7 +20,9 @@ import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
|
||||
@Composable
|
||||
internal fun AapUnavailableCard() {
|
||||
internal fun AapUnavailableCard(
|
||||
onOpenTracker: () -> Unit = {},
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -36,6 +40,13 @@ internal fun AapUnavailableCard() {
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(
|
||||
onClick = onOpenTracker,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Text(stringResource(R.string.device_settings_aap_unavailable_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package eu.darken.capod.main.ui.devicesettings.cards
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.settings.SettingsSection
|
||||
import eu.darken.capod.common.settings.SettingsSwitchItem
|
||||
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
/**
|
||||
* Apple's "Optimized Charge Limit" toggle (AAP setting 0x3B), shown for models that advertise
|
||||
* [PodModel.Features.hasDynamicEndOfCharge]. The wire format follows the Apple-bool convention
|
||||
* used by every other boolean setting.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BatteryCard(
|
||||
device: PodDevice,
|
||||
features: PodModel.Features,
|
||||
enabled: Boolean,
|
||||
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
|
||||
) {
|
||||
if (!features.hasDynamicEndOfCharge) return
|
||||
val cap = device.dynamicEndOfCharge ?: return
|
||||
|
||||
SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) {
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
title = stringResource(R.string.device_settings_charge_cap_label),
|
||||
subtitle = stringResource(R.string.device_settings_charge_cap_description),
|
||||
checked = cap.enabled,
|
||||
onCheckedChange = onDynamicEndOfChargeChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun BatteryCardEnabledPreview() = PreviewWrapper {
|
||||
val state = previewFullState(isPro = true)
|
||||
val device = state.device!!
|
||||
BatteryCard(
|
||||
device = device,
|
||||
features = PodModel.Features(hasDynamicEndOfCharge = true),
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun BatteryCardDisabledPreview() = PreviewWrapper {
|
||||
val state = previewFullState(isPro = true)
|
||||
val device = state.device!!
|
||||
BatteryCard(
|
||||
device = device,
|
||||
features = PodModel.Features(hasDynamicEndOfCharge = true),
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
+103
-45
@@ -18,6 +18,11 @@ import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
internal fun DeviceInfoBottomSheet(
|
||||
@@ -28,57 +33,110 @@ internal fun DeviceInfoBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.device_settings_info_details_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
items.forEach { item ->
|
||||
when (item) {
|
||||
is DeviceDetailItem.Single -> InfoRow(label = item.label, value = item.value)
|
||||
is DeviceDetailItem.Paired -> Row(modifier = Modifier.fillMaxWidth()) {
|
||||
InfoRow(
|
||||
label = item.start.label,
|
||||
value = item.start.value,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
InfoRow(
|
||||
label = item.end.label,
|
||||
value = item.end.value,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
DeviceInfoDetailsContent(items)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceInfoDetailsContent(items: List<DeviceDetailItem>) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.device_settings_info_details_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
items.forEach { item ->
|
||||
when (item) {
|
||||
is DeviceDetailItem.Single -> InfoRow(label = item.label, value = item.value)
|
||||
is DeviceDetailItem.Paired -> Row(modifier = Modifier.fillMaxWidth()) {
|
||||
InfoRow(
|
||||
label = item.start.label,
|
||||
value = item.start.value,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
InfoRow(
|
||||
label = item.end.label,
|
||||
value = item.end.value,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val previewDateFormatter: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.US).withZone(ZoneOffset.UTC)
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceInfoBottomSheetPreview() = PreviewWrapper {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Device Details",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
InfoRow(label = "Manufacturer", value = "Apple Inc.")
|
||||
InfoRow(label = "Serial Number", value = "W5J7KV0N04")
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
InfoRow(label = "Firmware", value = "7A305", modifier = Modifier.weight(1f))
|
||||
InfoRow(label = "Build", value = "8454624", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
InfoRow(label = "Left Pod Serial", value = "H3KL7HR926JY", modifier = Modifier.weight(1f))
|
||||
InfoRow(label = "Right Pod Serial", value = "H3KL2AYL26K0", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
|
||||
}
|
||||
}
|
||||
private fun DeviceInfoBottomSheetPreviewSameDayPairing() = PreviewWrapper {
|
||||
val pairedAt = Instant.parse("2023-10-16T12:00:00Z")
|
||||
val info = AapDeviceInfo(
|
||||
name = "AirPods Pro",
|
||||
modelNumber = "A2699",
|
||||
manufacturer = "Apple Inc.",
|
||||
serialNumber = "W5J7KV0N04",
|
||||
firmwareVersion = "81.2675000075000000.6814",
|
||||
hardwareVersion = "1.0.0",
|
||||
leftEarbudSerial = "H3KL7HR926JY",
|
||||
rightEarbudSerial = "H3KL2AYL26K0",
|
||||
marketingVersion = "8454768",
|
||||
leftEarbudFirstPaired = pairedAt,
|
||||
rightEarbudFirstPaired = pairedAt,
|
||||
)
|
||||
DeviceInfoDetailsContent(
|
||||
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
|
||||
previewDateFormatter.format(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceInfoBottomSheetPreviewMismatchedPairing() = PreviewWrapper {
|
||||
val info = AapDeviceInfo(
|
||||
name = "AirPods Pro",
|
||||
modelNumber = "A2699",
|
||||
manufacturer = "Apple Inc.",
|
||||
serialNumber = "W5J7KV0N04",
|
||||
firmwareVersion = "81.2675000075000000.6814",
|
||||
firmwareVersionPending = "82.1000000075000000.7000",
|
||||
hardwareVersion = "1.0.0",
|
||||
leftEarbudSerial = "H3KL7HR926JY",
|
||||
rightEarbudSerial = "H3KL2AYL26K0",
|
||||
marketingVersion = "8454768",
|
||||
leftEarbudFirstPaired = Instant.parse("2024-02-14T12:00:00Z"),
|
||||
rightEarbudFirstPaired = Instant.parse("2023-10-16T12:00:00Z"),
|
||||
)
|
||||
DeviceInfoDetailsContent(
|
||||
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
|
||||
previewDateFormatter.format(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceInfoBottomSheetPreviewOneSidedPairing() = PreviewWrapper {
|
||||
val info = AapDeviceInfo(
|
||||
name = "AirPods Pro",
|
||||
modelNumber = "A2699",
|
||||
manufacturer = "Apple Inc.",
|
||||
serialNumber = "W5J7KV0N04",
|
||||
firmwareVersion = "7A305",
|
||||
leftEarbudSerial = "H3KL7HR926JY",
|
||||
leftEarbudFirstPaired = Instant.parse("2023-10-16T12:00:00Z"),
|
||||
)
|
||||
DeviceInfoDetailsContent(
|
||||
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
|
||||
previewDateFormatter.format(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
|
||||
firmwareVersion = "7A305",
|
||||
leftEarbudSerial = "H3KL7HR926JY",
|
||||
rightEarbudSerial = "H3KL2AYL26K0",
|
||||
buildNumber = "8454624",
|
||||
marketingVersion = "8454624",
|
||||
),
|
||||
modelLabel = "AirPods Pro 2 (A2699)",
|
||||
systemBluetoothName = "AirPods Pro",
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package eu.darken.capod.main.ui.devicesettings.cards
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
|
||||
manufacturer = stringResource(R.string.device_settings_info_manufacturer_label),
|
||||
hardware = stringResource(R.string.device_settings_info_hardware_label),
|
||||
serial = stringResource(R.string.device_settings_info_serial_label),
|
||||
firmware = stringResource(R.string.device_settings_info_firmware_label),
|
||||
firmwarePending = stringResource(R.string.device_settings_info_firmware_pending_label),
|
||||
build = stringResource(R.string.device_settings_info_build_label),
|
||||
leftSerial = stringResource(R.string.device_settings_info_left_serial_label),
|
||||
rightSerial = stringResource(R.string.device_settings_info_right_serial_label),
|
||||
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
|
||||
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
|
||||
)
|
||||
|
||||
internal data class DeviceInfoDetailLabels(
|
||||
val manufacturer: String,
|
||||
val hardware: String,
|
||||
val serial: String,
|
||||
val firmware: String,
|
||||
val firmwarePending: String,
|
||||
val build: String,
|
||||
val leftSerial: String,
|
||||
val rightSerial: String,
|
||||
val leftBonded: String,
|
||||
val rightBonded: String,
|
||||
)
|
||||
|
||||
internal fun buildDeviceInfoDetailItems(
|
||||
info: AapDeviceInfo?,
|
||||
labels: DeviceInfoDetailLabels,
|
||||
formatDate: (Instant) -> String,
|
||||
): List<DeviceDetailItem> {
|
||||
if (info == null) return emptyList()
|
||||
return buildList {
|
||||
info.manufacturer.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.manufacturer, it))
|
||||
}
|
||||
info.hardwareVersion?.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.hardware, it))
|
||||
}
|
||||
info.serialNumber.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.serial, it))
|
||||
}
|
||||
info.firmwareVersion.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.firmware, it))
|
||||
}
|
||||
info.firmwareVersionPending?.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.firmwarePending, it))
|
||||
}
|
||||
info.marketingVersion?.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.build, it))
|
||||
}
|
||||
val leftSerial = info.leftEarbudSerial?.takeIf { it.isNotBlank() }
|
||||
val rightSerial = info.rightEarbudSerial?.takeIf { it.isNotBlank() }
|
||||
when {
|
||||
leftSerial != null && rightSerial != null -> add(
|
||||
DeviceDetailItem.Paired(
|
||||
start = DeviceDetailItem.Single(labels.leftSerial, leftSerial),
|
||||
end = DeviceDetailItem.Single(labels.rightSerial, rightSerial),
|
||||
)
|
||||
)
|
||||
leftSerial != null -> add(DeviceDetailItem.Single(labels.leftSerial, leftSerial))
|
||||
rightSerial != null -> add(DeviceDetailItem.Single(labels.rightSerial, rightSerial))
|
||||
}
|
||||
val leftBonded = info.leftEarbudFirstPaired?.let(formatDate)
|
||||
val rightBonded = info.rightEarbudFirstPaired?.let(formatDate)
|
||||
when {
|
||||
leftBonded != null && rightBonded != null -> add(
|
||||
DeviceDetailItem.Paired(
|
||||
start = DeviceDetailItem.Single(labels.leftBonded, leftBonded),
|
||||
end = DeviceDetailItem.Single(labels.rightBonded, rightBonded),
|
||||
)
|
||||
)
|
||||
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
|
||||
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,10 @@ internal fun NoiseControlCard(
|
||||
text = stringResource(R.string.press_controls_long_press_anc_cycle_info),
|
||||
type = InfoBoxType.INFO,
|
||||
action = {
|
||||
TextButton(onClick = onPressControlsClick) {
|
||||
TextButton(
|
||||
onClick = onPressControlsClick,
|
||||
enabled = enabled,
|
||||
) {
|
||||
Text(stringResource(R.string.device_settings_noise_control_open_press_controls_action))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -140,6 +140,7 @@ internal fun ReactionsCard(
|
||||
checked = sleepDet.enabled,
|
||||
onCheckedChange = onSleepDetectionChange,
|
||||
enabled = enabled,
|
||||
requiresUpgrade = !isPro,
|
||||
)
|
||||
if (sleepDet.enabled) {
|
||||
SettingsInfoBox(
|
||||
|
||||
@@ -59,6 +59,7 @@ import eu.darken.capod.main.ui.overview.cards.DualPodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
|
||||
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
||||
import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
||||
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
|
||||
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||
@@ -67,6 +68,22 @@ import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import java.time.Instant
|
||||
|
||||
internal fun profiledDeviceKey(
|
||||
device: PodDevice,
|
||||
index: Int,
|
||||
duplicateProfileIds: Set<String>,
|
||||
): String {
|
||||
val pid = requireNotNull(device.profileId)
|
||||
return if (pid !in duplicateProfileIds) {
|
||||
"profiled:$pid"
|
||||
} else {
|
||||
"profiled:$pid:${device.identifier ?: "idx:$index"}"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun unmatchedDeviceKey(device: PodDevice, index: Int): String =
|
||||
"unmatched:${device.identifier ?: "idx:$index"}"
|
||||
|
||||
@Composable
|
||||
fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
ErrorEventHandler(vm)
|
||||
@@ -158,7 +175,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
onUpgrade = { vm.onUpgrade() },
|
||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
|
||||
onDeviceSettings = { device ->
|
||||
if (currentState.showReactionsHint) vm.dismissReactionsHint()
|
||||
vm.goToDeviceSettings(device)
|
||||
},
|
||||
onEditProfile = { device -> vm.goToEditProfile(device) },
|
||||
onToggleDeviceExpansion = { device ->
|
||||
device.profileId?.let { vm.toggleDeviceExpansion(it) }
|
||||
@@ -286,9 +306,21 @@ fun OverviewScreen(
|
||||
|
||||
// 4. Profiled device cards (limited to 1 for free users)
|
||||
if (!state.isScanBlocked && state.isBluetoothEnabled) {
|
||||
if (state.showReactionsHint && state.visibleProfiledDevices.isNotEmpty()) {
|
||||
item(key = "reactions_hint") {
|
||||
ReactionsMovedHintCard()
|
||||
}
|
||||
}
|
||||
|
||||
val duplicateProfileIds = state.visibleProfiledDevices
|
||||
.mapNotNull { it.profileId }
|
||||
.groupingBy { it }
|
||||
.eachCount()
|
||||
.filterValues { it > 1 }
|
||||
.keys
|
||||
itemsIndexed(
|
||||
items = state.visibleProfiledDevices,
|
||||
key = { _, device -> requireNotNull(device.profileId) },
|
||||
key = { index, device -> profiledDeviceKey(device, index, duplicateProfileIds) },
|
||||
) { index, device ->
|
||||
val isCollapsed = !state.isExpanded(device, index)
|
||||
val isToggleable = state.isToggleable(device, index)
|
||||
@@ -337,10 +369,10 @@ fun OverviewScreen(
|
||||
}
|
||||
|
||||
if (state.showUnmatchedDevices) {
|
||||
items(
|
||||
itemsIndexed(
|
||||
items = state.unmatchedDevices,
|
||||
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" },
|
||||
) { device ->
|
||||
key = { index, device -> unmatchedDeviceKey(device, index) },
|
||||
) { _, device ->
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
isPro = state.upgradeInfo.isPro,
|
||||
@@ -414,6 +446,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
||||
),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||
showUnmatchedDevices = false,
|
||||
showReactionsHint = true,
|
||||
),
|
||||
onRequestPermission = {},
|
||||
onBluetoothSettings = {},
|
||||
|
||||
@@ -4,6 +4,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.value
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
@@ -130,7 +131,9 @@ class OverviewViewModel @Inject constructor(
|
||||
upgradeRepo.upgradeInfo,
|
||||
showUnmatchedDevices,
|
||||
userExpansionOverrides,
|
||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds ->
|
||||
generalSettings.reactionsHintDismissed.flow,
|
||||
profilesRepo.hadLegacyReactionData,
|
||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
|
||||
// Prune stale overrides (profiles that no longer exist)
|
||||
val currentProfileIds = profiles.map { it.id }.toSet()
|
||||
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
|
||||
@@ -145,6 +148,7 @@ class OverviewViewModel @Inject constructor(
|
||||
upgradeInfo = upgradeInfo,
|
||||
showUnmatchedDevices = showUnmatched,
|
||||
userExpandedIds = prunedExpandedIds,
|
||||
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed,
|
||||
)
|
||||
}.asLiveState()
|
||||
|
||||
@@ -160,6 +164,7 @@ class OverviewViewModel @Inject constructor(
|
||||
val upgradeInfo: UpgradeRepo.Info,
|
||||
val showUnmatchedDevices: Boolean,
|
||||
val userExpandedIds: Set<String> = emptySet(),
|
||||
val showReactionsHint: Boolean = false,
|
||||
) {
|
||||
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||
|
||||
@@ -252,6 +257,13 @@ class OverviewViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissReactionsHint() {
|
||||
log(TAG, INFO) { "dismissReactionsHint()" }
|
||||
launch {
|
||||
generalSettings.reactionsHintDismissed.value(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPermission(permission: Permission) {
|
||||
log(TAG, INFO) { "requestPermission($permission)" }
|
||||
requestPermissionEvent.tryEmit(permission)
|
||||
|
||||
@@ -24,14 +24,13 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.GridView
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material.icons.twotone.Warning
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -52,6 +51,7 @@ import eu.darken.capod.main.ui.overview.cards.components.BatteryCapsule
|
||||
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
|
||||
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
|
||||
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
|
||||
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
|
||||
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
|
||||
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
|
||||
import eu.darken.capod.main.ui.overview.cards.components.StatusChipRow
|
||||
@@ -61,6 +61,7 @@ import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
||||
@@ -167,7 +168,7 @@ fun DualPodsCard(
|
||||
}
|
||||
|
||||
if (device.profileId != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
@@ -175,15 +176,15 @@ fun DualPodsCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (device.profileId != null && device.address == null && onEditProfile != null) {
|
||||
IconButton(onClick = onEditProfile) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Warning,
|
||||
contentDescription = stringResource(R.string.overview_card_missing_paired_device_cd),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCollapsed
|
||||
&& device.profileId != null
|
||||
&& !device.hasSelectedPairedDevice
|
||||
&& onEditProfile != null
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
MissingPairedDeviceBanner(onClick = onEditProfile)
|
||||
}
|
||||
|
||||
if (isCollapsed) {
|
||||
@@ -227,7 +228,8 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
PodGauge(
|
||||
iconRes = device.leftPodIcon,
|
||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
||||
isCharging = device.isLeftPodCharging ?: false,
|
||||
chargingState = device.leftPodChargingState
|
||||
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||
isInEar = device.isLeftInEar ?: false,
|
||||
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
||||
isMicrophone = device.isLeftPodMicrophone ?: false,
|
||||
@@ -238,7 +240,8 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
PodGauge(
|
||||
iconRes = device.rightPodIcon,
|
||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
||||
isCharging = device.isRightPodCharging ?: false,
|
||||
chargingState = device.rightPodChargingState
|
||||
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||
isInEar = device.isRightInEar ?: false,
|
||||
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
||||
isMicrophone = device.isRightPodMicrophone ?: false,
|
||||
@@ -292,7 +295,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
private fun PodGauge(
|
||||
iconRes: Int,
|
||||
batteryPercent: Float,
|
||||
isCharging: Boolean,
|
||||
chargingState: AapPodState.ChargingState?,
|
||||
isInEar: Boolean,
|
||||
showEarDetection: Boolean,
|
||||
isMicrophone: Boolean,
|
||||
@@ -370,12 +373,13 @@ private fun PodGauge(
|
||||
|
||||
// Status chips
|
||||
StatusChipRow(
|
||||
isCharging = isCharging,
|
||||
chargingState = chargingState,
|
||||
isInEar = isInEar,
|
||||
showEarDetection = showEarDetection,
|
||||
isMicrophone = isMicrophone,
|
||||
showMicrophone = showMicrophone,
|
||||
chargingLabel = stringResource(R.string.pods_charging_label),
|
||||
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
|
||||
inEarLabel = stringResource(R.string.pods_inear_label),
|
||||
microphoneLabel = stringResource(R.string.pods_microphone_label),
|
||||
)
|
||||
@@ -420,11 +424,17 @@ private fun CaseRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (device.isCaseCharging == true) {
|
||||
StatusChip(
|
||||
when (val caseState = device.caseChargingState
|
||||
?: device.isCaseCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
|
||||
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_optimized_label),
|
||||
)
|
||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val lidState = device.caseLidState
|
||||
@@ -490,7 +500,7 @@ private fun DualPodsCardCollapsedPreview() = PreviewWrapper {
|
||||
@Composable
|
||||
private fun DualPodsCardMissingAddressPreview() = PreviewWrapper {
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
device = MockPodDataProvider.dualPodMissingPairedDevice(),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
onEditProfile = {},
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
|
||||
@Composable
|
||||
fun ReactionsMovedHintCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.overview_reactions_hint_title),
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.overview_reactions_hint_body),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 18.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun ReactionsMovedHintCardPreview() = PreviewWrapper {
|
||||
ReactionsMovedHintCard()
|
||||
}
|
||||
@@ -24,13 +24,12 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.Hearing
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material.icons.twotone.Warning
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -50,6 +49,7 @@ import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
|
||||
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
|
||||
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
|
||||
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
|
||||
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
|
||||
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
|
||||
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
|
||||
import eu.darken.capod.common.SystemTimeSource
|
||||
@@ -58,6 +58,7 @@ import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import java.time.Instant
|
||||
@@ -149,7 +150,7 @@ fun SinglePodsCard(
|
||||
}
|
||||
|
||||
if (device.profileId != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
@@ -157,15 +158,15 @@ fun SinglePodsCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (device.profileId != null && device.address == null && onEditProfile != null) {
|
||||
IconButton(onClick = onEditProfile) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Warning,
|
||||
contentDescription = stringResource(R.string.overview_card_missing_paired_device_cd),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCollapsed
|
||||
&& device.profileId != null
|
||||
&& !device.hasSelectedPairedDevice
|
||||
&& onEditProfile != null
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
MissingPairedDeviceBanner(onClick = onEditProfile)
|
||||
}
|
||||
|
||||
if (isCollapsed) {
|
||||
@@ -267,11 +268,17 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (device.isHeadsetBeingCharged == true) {
|
||||
StatusChip(
|
||||
when (val headsetState = device.headsetChargingState
|
||||
?: device.isHeadsetBeingCharged?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
|
||||
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_optimized_label),
|
||||
)
|
||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
if (device.isBeingWorn == true) {
|
||||
StatusChip(
|
||||
@@ -359,7 +366,7 @@ private fun SinglePodsCardCollapsedPreview() = PreviewWrapper {
|
||||
@Composable
|
||||
private fun SinglePodsCardMissingAddressPreview() = PreviewWrapper {
|
||||
SinglePodsCard(
|
||||
device = MockPodDataProvider.singlePodMonitored(),
|
||||
device = MockPodDataProvider.singlePodMissingPairedDevice(),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
onEditProfile = {},
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package eu.darken.capod.main.ui.overview.cards.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
|
||||
@Composable
|
||||
fun MissingPairedDeviceBanner(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.semantics(mergeDescendants = true) {},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.overview_card_missing_paired_device),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun MissingPairedDeviceBannerPreview() = PreviewWrapper {
|
||||
MissingPairedDeviceBanner(onClick = {})
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
|
||||
@Composable
|
||||
fun StatusChip(
|
||||
@@ -60,12 +61,13 @@ fun StatusChip(
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun StatusChipRow(
|
||||
isCharging: Boolean,
|
||||
chargingState: AapPodState.ChargingState?,
|
||||
isInEar: Boolean,
|
||||
showEarDetection: Boolean,
|
||||
isMicrophone: Boolean,
|
||||
showMicrophone: Boolean,
|
||||
chargingLabel: String,
|
||||
chargingOptimizedLabel: String,
|
||||
inEarLabel: String,
|
||||
microphoneLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -75,11 +77,16 @@ fun StatusChipRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (isCharging) {
|
||||
StatusChip(
|
||||
when (chargingState) {
|
||||
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = chargingOptimizedLabel,
|
||||
)
|
||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = chargingLabel,
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
if (showMicrophone && isMicrophone) {
|
||||
StatusChip(
|
||||
@@ -106,12 +113,29 @@ private fun StatusChipChargingPreview() = PreviewWrapper {
|
||||
@Composable
|
||||
private fun StatusChipRowAllPreview() = PreviewWrapper {
|
||||
StatusChipRow(
|
||||
isCharging = true,
|
||||
chargingState = AapPodState.ChargingState.CHARGING,
|
||||
isInEar = true,
|
||||
showEarDetection = true,
|
||||
isMicrophone = true,
|
||||
showMicrophone = true,
|
||||
chargingLabel = "Charging",
|
||||
chargingOptimizedLabel = "Optimized",
|
||||
inEarLabel = "In Ear",
|
||||
microphoneLabel = "Mic",
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun StatusChipRowOptimizedPreview() = PreviewWrapper {
|
||||
StatusChipRow(
|
||||
chargingState = AapPodState.ChargingState.CHARGING_OPTIMIZED,
|
||||
isInEar = false,
|
||||
showEarDetection = true,
|
||||
isMicrophone = true,
|
||||
showMicrophone = true,
|
||||
chargingLabel = "Charging",
|
||||
chargingOptimizedLabel = "Optimized",
|
||||
inEarLabel = "In Ear",
|
||||
microphoneLabel = "Mic",
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -70,6 +71,18 @@ fun PressControlsScreenHost(
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
val currentState = state ?: return
|
||||
|
||||
val isAapConnected = currentState.device?.isAapConnected == true
|
||||
var hasSeenAapConnected by rememberSaveable(profileId) { mutableStateOf(false) }
|
||||
var didAutoNavigate by rememberSaveable(profileId) { mutableStateOf(false) }
|
||||
LaunchedEffect(profileId, isAapConnected) {
|
||||
if (isAapConnected) {
|
||||
hasSeenAapConnected = true
|
||||
} else if (hasSeenAapConnected && !didAutoNavigate) {
|
||||
didAutoNavigate = true
|
||||
vm.navUp()
|
||||
}
|
||||
}
|
||||
|
||||
PressControlsScreen(
|
||||
state = currentState,
|
||||
snackbarHostState = snackbarHostState,
|
||||
|
||||
@@ -100,6 +100,13 @@ fun AcknowledgementsScreen(
|
||||
onClick = { onOpenUrl("https://github.com/furiousMAC/continuity") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "apple-wireshark",
|
||||
subtitle = "Thanks to Pablo Aul for the Wireshark dissector catalog of the AAP/AACP protocol.",
|
||||
onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "crowdin.com",
|
||||
@@ -110,13 +117,6 @@ fun AcknowledgementsScreen(
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_licenses_label))
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "Glide",
|
||||
subtitle = "An image loading and caching library for Android focused on smooth scrolling. (Multiple licenses)",
|
||||
onClick = { onOpenUrl("https://github.com/bumptech/glide") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "Material Design Icons",
|
||||
@@ -145,6 +145,13 @@ fun AcknowledgementsScreen(
|
||||
onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "apple-wireshark",
|
||||
subtitle = "Wireshark dissector plugins for Apple protocols. (GPL-3.0)",
|
||||
onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "Kotlin",
|
||||
@@ -166,13 +173,6 @@ fun AcknowledgementsScreen(
|
||||
onClick = { onOpenUrl("https://source.android.com/source/licenses.html") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "Android",
|
||||
subtitle = "The Android robot is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License.",
|
||||
onClick = { onOpenUrl("https://developer.android.com/distribute/tools/promote/brand.html") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import androidx.compose.material.icons.twotone.FilterList
|
||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||
import androidx.compose.material.icons.twotone.Notifications
|
||||
import androidx.compose.material.icons.twotone.Palette
|
||||
import androidx.compose.material.icons.twotone.SettingsBluetooth
|
||||
import androidx.compose.material.icons.automirrored.twotone.ViewList
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -45,7 +44,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
@@ -70,7 +68,6 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
||||
state = it,
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
|
||||
onScannerModeSelected = { mode -> vm.setScannerMode(mode) },
|
||||
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
|
||||
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
|
||||
onDebugSettings = { vm.goToDebugSettings() },
|
||||
@@ -90,7 +87,6 @@ fun GeneralSettingsScreen(
|
||||
state: GeneralSettingsViewModel.State,
|
||||
onNavigateUp: () -> Unit,
|
||||
onMonitorModeSelected: (MonitorMode) -> Unit,
|
||||
onScannerModeSelected: (ScannerMode) -> Unit,
|
||||
onShowConnectedNotificationChanged: (Boolean) -> Unit,
|
||||
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
|
||||
onDebugSettings: () -> Unit,
|
||||
@@ -104,7 +100,6 @@ fun GeneralSettingsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showMonitorModeDialog by remember { mutableStateOf(false) }
|
||||
var showScannerModeDialog by remember { mutableStateOf(false) }
|
||||
var showColorDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
|
||||
@@ -138,14 +133,6 @@ fun GeneralSettingsScreen(
|
||||
onClick = { showMonitorModeDialog = true },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_scanner_mode_label),
|
||||
subtitle = stringResource(state.scannerMode.labelRes),
|
||||
icon = Icons.TwoTone.SettingsBluetooth,
|
||||
onClick = { showScannerModeDialog = true },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
|
||||
}
|
||||
@@ -313,20 +300,6 @@ fun GeneralSettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showScannerModeDialog) {
|
||||
ListPreferenceDialog(
|
||||
title = stringResource(R.string.settings_scanner_mode_label),
|
||||
entries = ScannerMode.entries,
|
||||
selectedEntry = state.scannerMode,
|
||||
onEntrySelected = {
|
||||
onScannerModeSelected(it)
|
||||
showScannerModeDialog = false
|
||||
},
|
||||
entryLabel = { stringResource(it.labelRes) },
|
||||
onDismiss = { showScannerModeDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showColorDialog) {
|
||||
ThemeColorSelectorDialog(
|
||||
selectedColor = state.themeState.color,
|
||||
@@ -342,7 +315,6 @@ fun GeneralSettingsScreen(
|
||||
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
|
||||
isPro = isPro,
|
||||
monitorMode = MonitorMode.AUTOMATIC,
|
||||
scannerMode = ScannerMode.BALANCED,
|
||||
showConnectedNotification = true,
|
||||
keepNotificationAfterDisconnect = false,
|
||||
isOffloadedFilteringDisabled = false,
|
||||
@@ -358,7 +330,6 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
|
||||
state = previewGeneralState(isPro = true),
|
||||
onNavigateUp = {},
|
||||
onMonitorModeSelected = {},
|
||||
onScannerModeSelected = {},
|
||||
onShowConnectedNotificationChanged = {},
|
||||
onKeepNotificationAfterDisconnectChanged = {},
|
||||
onDebugSettings = {},
|
||||
@@ -375,7 +346,6 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
|
||||
state = previewGeneralState(isPro = false),
|
||||
onNavigateUp = {},
|
||||
onMonitorModeSelected = {},
|
||||
onScannerModeSelected = {},
|
||||
onShowConnectedNotificationChanged = {},
|
||||
onKeepNotificationAfterDisconnectChanged = {},
|
||||
onDebugSettings = {},
|
||||
|
||||
+4
-13
@@ -1,7 +1,6 @@
|
||||
package eu.darken.capod.main.ui.settings.general
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -32,7 +31,6 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
data class State(
|
||||
val isPro: Boolean,
|
||||
val monitorMode: MonitorMode,
|
||||
val scannerMode: ScannerMode,
|
||||
val showConnectedNotification: Boolean,
|
||||
val keepNotificationAfterDisconnect: Boolean,
|
||||
val isOffloadedFilteringDisabled: Boolean,
|
||||
@@ -46,12 +44,11 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
val state = combine(
|
||||
combine(
|
||||
generalSettings.monitorMode.flow,
|
||||
generalSettings.scannerMode.flow,
|
||||
generalSettings.useExtraMonitorNotification.flow,
|
||||
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
||||
) { monitorMode, scannerMode, showNotif, keepNotif ->
|
||||
) { monitorMode, showNotif, keepNotif ->
|
||||
@Suppress("USELESS_CAST")
|
||||
arrayOf<Any>(monitorMode as Any, scannerMode as Any, showNotif as Any, keepNotif as Any)
|
||||
arrayOf<Any>(monitorMode as Any, showNotif as Any, keepNotif as Any)
|
||||
},
|
||||
combine(
|
||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||
@@ -67,9 +64,8 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
State(
|
||||
isPro = isPro,
|
||||
monitorMode = general[0] as MonitorMode,
|
||||
scannerMode = general[1] as ScannerMode,
|
||||
showConnectedNotification = general[2] as Boolean,
|
||||
keepNotificationAfterDisconnect = general[3] as Boolean,
|
||||
showConnectedNotification = general[1] as Boolean,
|
||||
keepNotificationAfterDisconnect = general[2] as Boolean,
|
||||
isOffloadedFilteringDisabled = compat[0] as Boolean,
|
||||
isOffloadedBatchingDisabled = compat[1] as Boolean,
|
||||
useIndirectScanResultCallback = compat[2] as Boolean,
|
||||
@@ -82,11 +78,6 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
generalSettings.monitorMode.valueBlocking = mode
|
||||
}
|
||||
|
||||
fun setScannerMode(mode: ScannerMode) {
|
||||
log(TAG, INFO) { "setScannerMode($mode)" }
|
||||
generalSettings.scannerMode.valueBlocking = mode
|
||||
}
|
||||
|
||||
fun setShowConnectedNotification(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
|
||||
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import androidx.annotation.Keep
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
@@ -23,8 +24,9 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class AncGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
@@ -43,17 +45,15 @@ class AncGlanceWidget : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val ep: AncWidgetEntryPoint
|
||||
val appWidgetId: Int
|
||||
val initialIsPro: Boolean
|
||||
val initialProfileId: String?
|
||||
val cachedDevice: PodDevice?
|
||||
|
||||
try {
|
||||
ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java)
|
||||
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
||||
initialIsPro = ep.upgradeRepo().isPro()
|
||||
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
|
||||
ep.widgetSettings().migrateLegacyConfigIfNeeded(
|
||||
appWidgetId,
|
||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
||||
provideContent {
|
||||
@@ -73,26 +73,40 @@ class AncGlanceWidget : GlanceAppWidget() {
|
||||
}
|
||||
|
||||
provideContent {
|
||||
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
|
||||
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
|
||||
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
||||
val widthDp = LocalSize.current.width
|
||||
val heightDp = LocalSize.current.height
|
||||
|
||||
val state = try {
|
||||
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||
val theme = WidgetTheme.fromBundle(
|
||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
|
||||
)
|
||||
// Glance keeps the content session alive and does not restart provideGlance()
|
||||
// for every update(). Observe a widget-key-deduped device flow so visible state
|
||||
// changes update active sessions without recomposing on every BLE advertisement.
|
||||
val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
|
||||
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
|
||||
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
||||
|
||||
val liveDevice = devices.firstOrNull { it.profileId == profileId }
|
||||
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
|
||||
|
||||
val profileLabel = profileId?.let { pid ->
|
||||
profiles.firstOrNull { it.id == pid }?.label
|
||||
val state = if (config != null) {
|
||||
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
|
||||
.onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
|
||||
.getOrDefault(false)
|
||||
val initialDevice = remember(config.profileId) {
|
||||
config.profileId?.let { pid ->
|
||||
runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } }
|
||||
.onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
val device by config.profileId
|
||||
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
|
||||
?.collectAsState(initial = initialDevice)
|
||||
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
|
||||
val profileLabel = config.profileId?.let { pid ->
|
||||
runCatching {
|
||||
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
|
||||
}
|
||||
.onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
|
||||
|
||||
val widthCells = getCellsForSize(widthDp.value.toInt())
|
||||
val heightCells = getCellsForSize(heightDp.value.toInt())
|
||||
@@ -108,14 +122,13 @@ class AncGlanceWidget : GlanceAppWidget() {
|
||||
AncWidgetRenderStateMapper.map(
|
||||
context = context,
|
||||
device = device,
|
||||
theme = theme,
|
||||
theme = config.theme,
|
||||
isPro = isPro,
|
||||
hasConfiguredProfile = profileId != null,
|
||||
hasConfiguredProfile = config.profileId != null,
|
||||
profileLabel = profileLabel,
|
||||
layout = layout,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
|
||||
} else {
|
||||
AncWidgetRenderState.Message(
|
||||
theme = WidgetTheme.DEFAULT,
|
||||
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||
@@ -158,7 +171,7 @@ class AncGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
private fun getCellsForSize(size: Int): Int {
|
||||
var n = 2
|
||||
while (70 * n - 30 < size) {
|
||||
while (70 * n - 30 <= size) {
|
||||
++n
|
||||
}
|
||||
return n - 1
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.annotation.Keep
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
@@ -30,6 +29,7 @@ class AncModeActionCallback : ActionCallback {
|
||||
fun aapConnectionManager(): AapConnectionManager
|
||||
fun widgetSettings(): WidgetSettings
|
||||
fun deviceMonitor(): DeviceMonitor
|
||||
fun widgetManager(): WidgetManager
|
||||
}
|
||||
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
@@ -53,7 +53,7 @@ class AncModeActionCallback : ActionCallback {
|
||||
|
||||
val ep = EntryPointAccessors.fromApplication(context, AncModeCallbackEntryPoint::class.java)
|
||||
|
||||
val profileId = ep.widgetSettings().getWidgetProfile(widgetId)
|
||||
val profileId = ep.widgetSettings().getWidgetConfig(widgetId).profileId
|
||||
if (profileId == null) {
|
||||
log(TAG, ERROR) { "onAction: no profile for widgetId=$widgetId" }
|
||||
return
|
||||
@@ -62,14 +62,14 @@ class AncModeActionCallback : ActionCallback {
|
||||
val device = ep.deviceMonitor().getDeviceForProfile(profileId)
|
||||
if (device == null) {
|
||||
log(TAG, ERROR) { "onAction: no device for profileId=$profileId" }
|
||||
AncGlanceWidget().updateAll(context)
|
||||
ep.widgetManager().refreshWidgets()
|
||||
return
|
||||
}
|
||||
|
||||
val address = device.address
|
||||
if (address == null) {
|
||||
log(TAG, ERROR) { "onAction: device has no address" }
|
||||
AncGlanceWidget().updateAll(context)
|
||||
ep.widgetManager().refreshWidgets()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class AncModeActionCallback : ActionCallback {
|
||||
log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" }
|
||||
}
|
||||
|
||||
AncGlanceWidget().updateAll(context)
|
||||
ep.widgetManager().refreshWidgets()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import androidx.annotation.Keep
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
@@ -23,8 +24,9 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
@@ -43,26 +45,26 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val ep: WidgetEntryPoint
|
||||
val appWidgetId: Int
|
||||
val initialIsPro: Boolean
|
||||
val initialProfileId: String?
|
||||
val cachedDevice: PodDevice?
|
||||
|
||||
try {
|
||||
ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
|
||||
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
||||
initialIsPro = ep.upgradeRepo().isPro()
|
||||
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
|
||||
ep.widgetSettings().migrateLegacyConfigIfNeeded(
|
||||
appWidgetId,
|
||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
||||
provideContent {
|
||||
val layout = BatteryLayout.forCells(getCellsForSize(LocalSize.current.width.value.toInt()))
|
||||
GlanceWidgetContent(
|
||||
state = WidgetRenderState.Message(
|
||||
theme = WidgetTheme.DEFAULT,
|
||||
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
||||
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
||||
layout = layout,
|
||||
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
|
||||
),
|
||||
context = context,
|
||||
@@ -72,45 +74,57 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
}
|
||||
|
||||
provideContent {
|
||||
// Composable reads — must be outside try-catch
|
||||
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
|
||||
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
|
||||
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
||||
val widthDp = LocalSize.current.width
|
||||
val layout = BatteryLayout.forCells(getCellsForSize(widthDp.value.toInt()))
|
||||
|
||||
val state = try {
|
||||
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||
val theme = WidgetTheme.fromBundle(
|
||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
|
||||
)
|
||||
// Glance keeps the content session alive and does not restart provideGlance()
|
||||
// for every update(). Observe a widget-key-deduped device flow so visible state
|
||||
// changes update active sessions without recomposing on every BLE advertisement.
|
||||
val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
|
||||
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
|
||||
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
||||
|
||||
val liveDevice = devices.firstOrNull { it.profileId == profileId }
|
||||
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
|
||||
|
||||
val profileLabel = profileId?.let { pid ->
|
||||
profiles.firstOrNull { it.id == pid }?.label
|
||||
val state = if (config != null) {
|
||||
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
|
||||
.onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
|
||||
.getOrDefault(false)
|
||||
val initialDevice = remember(config.profileId) {
|
||||
config.profileId?.let { pid ->
|
||||
runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } }
|
||||
.onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
val device by config.profileId
|
||||
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
|
||||
?.collectAsState(initial = initialDevice)
|
||||
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
|
||||
val profileLabel = config.profileId?.let { pid ->
|
||||
runCatching {
|
||||
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
|
||||
}
|
||||
.onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
val isWide = getCellsForSize(widthDp.value.toInt()) >= 5
|
||||
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
|
||||
|
||||
WidgetRenderStateMapper.map(
|
||||
context = context,
|
||||
device = device,
|
||||
theme = theme,
|
||||
theme = config.theme,
|
||||
isPro = isPro,
|
||||
hasConfiguredProfile = profileId != null,
|
||||
hasConfiguredProfile = config.profileId != null,
|
||||
profileLabel = profileLabel,
|
||||
isWide = isWide,
|
||||
layout = layout,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
|
||||
} else {
|
||||
WidgetRenderState.Message(
|
||||
theme = WidgetTheme.DEFAULT,
|
||||
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
||||
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
||||
layout = layout,
|
||||
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
|
||||
)
|
||||
}
|
||||
@@ -137,7 +151,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
*/
|
||||
private fun getCellsForSize(size: Int): Int {
|
||||
var n = 2
|
||||
while (70 * n - 30 < size) {
|
||||
while (70 * n - 30 <= size) {
|
||||
++n
|
||||
}
|
||||
return n - 1
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import eu.darken.capod.R
|
||||
@@ -87,86 +88,51 @@ private fun DualPodPreview(
|
||||
val iconColor = Color(state.resolvedIconColor)
|
||||
val iconTint = ColorFilter.tint(iconColor)
|
||||
|
||||
if (state.isWide) {
|
||||
// Wide layout: left | case | right in a horizontal row
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Left pod
|
||||
PodItemRow(
|
||||
icon = state.leftIcon,
|
||||
percent = state.leftPercent,
|
||||
charging = state.leftCharging,
|
||||
inEar = state.leftInEar,
|
||||
when (state.layout) {
|
||||
BatteryLayout.WIDE -> {
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
|
||||
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
|
||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40)
|
||||
}
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
iconSize = 40,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
// Case
|
||||
PodItemRow(
|
||||
icon = state.caseIcon,
|
||||
percent = state.casePercent,
|
||||
charging = state.caseCharging,
|
||||
inEar = false,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
iconSize = 40,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
// Right pod
|
||||
PodItemRow(
|
||||
icon = state.rightIcon,
|
||||
percent = state.rightPercent,
|
||||
charging = state.rightCharging,
|
||||
inEar = state.rightInEar,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
iconSize = 40,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Compact layout: vertical stack
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
PodItemRow(
|
||||
icon = state.leftIcon,
|
||||
percent = state.leftPercent,
|
||||
charging = state.leftCharging,
|
||||
inEar = state.leftInEar,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
)
|
||||
PodItemRow(
|
||||
icon = state.rightIcon,
|
||||
percent = state.rightPercent,
|
||||
charging = state.rightCharging,
|
||||
inEar = state.rightInEar,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
)
|
||||
PodItemRow(
|
||||
icon = state.caseIcon,
|
||||
percent = state.casePercent,
|
||||
charging = state.caseCharging,
|
||||
inEar = false,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
)
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
)
|
||||
|
||||
BatteryLayout.NARROW -> {
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint)
|
||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint)
|
||||
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BatteryLayout.TINY_COLUMN -> {
|
||||
WidgetContainer(
|
||||
bgColor = bgColor,
|
||||
modifier = modifier,
|
||||
horizontalPadding = 4.dp,
|
||||
verticalPadding = 0.dp,
|
||||
) {
|
||||
TinyPodItem(state.leftIcon, state.leftPercent, textColor, iconTint)
|
||||
TinyPodItem(state.rightIcon, state.rightPercent, textColor, iconTint)
|
||||
TinyPodItem(state.caseIcon, state.casePercent, textColor, iconTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,45 +146,60 @@ private fun SinglePodPreview(
|
||||
val textColor = Color(state.resolvedTextColor)
|
||||
val iconTint = ColorFilter.tint(Color(state.resolvedIconColor))
|
||||
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(state.batteryIcon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(state.percent.toBatteryOrNull()),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (state.worn) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
when (state.layout) {
|
||||
BatteryLayout.WIDE, BatteryLayout.NARROW -> {
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(state.batteryIcon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(state.percent.toBatteryOrNull()),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (state.worn) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
)
|
||||
|
||||
BatteryLayout.TINY_COLUMN -> {
|
||||
WidgetContainer(
|
||||
bgColor = bgColor,
|
||||
modifier = modifier,
|
||||
horizontalPadding = 4.dp,
|
||||
verticalPadding = 0.dp,
|
||||
) {
|
||||
TinyPodItem(state.batteryIcon, state.percent, textColor, iconTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,17 +210,25 @@ private fun MessagePreview(
|
||||
) {
|
||||
val bgColor = Color(state.resolvedBgColor)
|
||||
val textColor = Color(state.resolvedTextColor)
|
||||
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
|
||||
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
WidgetContainer(
|
||||
bgColor = bgColor,
|
||||
modifier = modifier,
|
||||
horizontalPadding = if (isCompact) 4.dp else 16.dp,
|
||||
verticalPadding = if (isCompact) 0.dp else 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = state.primaryText,
|
||||
fontSize = 12.sp,
|
||||
fontSize = if (isCompact) 10.sp else 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = if (isCompact) 2 else Int.MAX_VALUE,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (state.secondaryText != null) {
|
||||
if (!isCompact && state.secondaryText != null) {
|
||||
Text(
|
||||
text = state.secondaryText,
|
||||
fontSize = 12.sp,
|
||||
@@ -260,11 +249,17 @@ private fun LoadingPreview(
|
||||
) {
|
||||
val bgColor = Color(state.resolvedBgColor)
|
||||
val textColor = Color(state.resolvedTextColor)
|
||||
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
|
||||
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
WidgetContainer(
|
||||
bgColor = bgColor,
|
||||
modifier = modifier,
|
||||
horizontalPadding = if (isCompact) 4.dp else 16.dp,
|
||||
verticalPadding = if (isCompact) 0.dp else 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = "…",
|
||||
fontSize = 12.sp,
|
||||
fontSize = if (isCompact) 10.sp else 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -277,13 +272,15 @@ private fun LoadingPreview(
|
||||
private fun WidgetContainer(
|
||||
bgColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalPadding: Dp = 16.dp,
|
||||
verticalPadding: Dp = 4.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(bgColor)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
@@ -338,6 +335,34 @@ private fun PodItemRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TinyPodItem(
|
||||
icon: Int,
|
||||
percent: Float,
|
||||
textColor: Color,
|
||||
iconTint: ColorFilter,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent.toBatteryOrNull()),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceLabel(
|
||||
label: String?,
|
||||
@@ -363,12 +388,30 @@ private fun formatPercent(percent: Float?): String {
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun PreviewDualCompact() = PreviewWrapper {
|
||||
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod())
|
||||
private fun PreviewDualTiny11() = PreviewWrapper {
|
||||
ComposeWidgetPreview(
|
||||
state = WidgetRenderState.previewDualPod(layout = BatteryLayout.TINY_COLUMN),
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun PreviewDualTinyTall() = PreviewWrapper {
|
||||
ComposeWidgetPreview(
|
||||
state = WidgetRenderState.previewDualPod(layout = BatteryLayout.TINY_COLUMN),
|
||||
modifier = Modifier.size(width = 40.dp, height = 110.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun PreviewDualNarrow() = PreviewWrapper {
|
||||
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(layout = BatteryLayout.NARROW))
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun PreviewDualWide() = PreviewWrapper {
|
||||
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(isWide = true))
|
||||
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(layout = BatteryLayout.WIDE))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package eu.darken.capod.main.ui.widget
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.ColorFilter
|
||||
@@ -53,31 +54,47 @@ private fun GlanceDualPod(
|
||||
state: WidgetRenderState.DualPod,
|
||||
clickModifier: GlanceModifier,
|
||||
) {
|
||||
val textStyle = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
|
||||
|
||||
if (state.isWide) {
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40)
|
||||
when (state.layout) {
|
||||
BatteryLayout.WIDE -> {
|
||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40)
|
||||
}
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
} else {
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
|
||||
BatteryLayout.NARROW -> {
|
||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
BatteryLayout.TINY_COLUMN -> {
|
||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||
GlanceWidgetRoot(
|
||||
bgColor = state.resolvedBgColor,
|
||||
clickModifier = clickModifier,
|
||||
horizontalPadding = 4.dp,
|
||||
verticalPadding = 0.dp,
|
||||
) {
|
||||
GlanceTinyPodItem(state.leftIcon, state.leftPercent, textStyle, iconTint)
|
||||
GlanceTinyPodItem(state.rightIcon, state.rightPercent, textStyle, iconTint)
|
||||
GlanceTinyPodItem(state.caseIcon, state.casePercent, textStyle, iconTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,47 +104,60 @@ private fun GlanceSinglePod(
|
||||
state: WidgetRenderState.SinglePod,
|
||||
clickModifier: GlanceModifier,
|
||||
) {
|
||||
val textStyle = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
|
||||
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(state.batteryIcon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(state.percent.toBatteryOrNull()),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
when (state.layout) {
|
||||
BatteryLayout.WIDE, BatteryLayout.NARROW -> {
|
||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(state.batteryIcon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(state.percent.toBatteryOrNull()),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (state.worn) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
if (state.worn) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
|
||||
BatteryLayout.TINY_COLUMN -> {
|
||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||
GlanceWidgetRoot(
|
||||
bgColor = state.resolvedBgColor,
|
||||
clickModifier = clickModifier,
|
||||
horizontalPadding = 4.dp,
|
||||
verticalPadding = 0.dp,
|
||||
) {
|
||||
GlanceTinyPodItem(state.batteryIcon, state.percent, textStyle, iconTint)
|
||||
}
|
||||
}
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,20 +166,29 @@ private fun GlanceMessage(
|
||||
state: WidgetRenderState.Message,
|
||||
clickModifier: GlanceModifier,
|
||||
) {
|
||||
val textStyle = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
|
||||
val fontSize = if (isCompact) 10.sp else 12.sp
|
||||
val hPad = if (isCompact) 4.dp else 16.dp
|
||||
val vPad = if (isCompact) 0.dp else 4.dp
|
||||
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
GlanceWidgetRoot(
|
||||
bgColor = state.resolvedBgColor,
|
||||
clickModifier = clickModifier,
|
||||
horizontalPadding = hPad,
|
||||
verticalPadding = vPad,
|
||||
) {
|
||||
Text(
|
||||
text = state.primaryText,
|
||||
style = textStyle,
|
||||
style = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
maxLines = if (isCompact) 2 else Int.MAX_VALUE,
|
||||
)
|
||||
if (state.secondaryText != null) {
|
||||
if (!isCompact && state.secondaryText != null) {
|
||||
Text(
|
||||
text = state.secondaryText,
|
||||
style = TextStyle(
|
||||
@@ -169,12 +208,18 @@ private fun GlanceLoading(
|
||||
state: WidgetRenderState.Loading,
|
||||
clickModifier: GlanceModifier,
|
||||
) {
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
|
||||
GlanceWidgetRoot(
|
||||
bgColor = state.resolvedBgColor,
|
||||
clickModifier = clickModifier,
|
||||
horizontalPadding = if (isCompact) 4.dp else 16.dp,
|
||||
verticalPadding = if (isCompact) 0.dp else 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = "…",
|
||||
style = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
fontSize = if (isCompact) 10.sp else 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
@@ -187,13 +232,15 @@ private fun GlanceLoading(
|
||||
private fun GlanceWidgetRoot(
|
||||
bgColor: Int,
|
||||
clickModifier: GlanceModifier,
|
||||
horizontalPadding: Dp = 16.dp,
|
||||
verticalPadding: Dp = 4.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = clickModifier
|
||||
.fillMaxSize()
|
||||
.background(fixedColor(bgColor))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -246,6 +293,33 @@ private fun GlancePodItem(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceTinyPodItem(
|
||||
icon: Int,
|
||||
percent: Float,
|
||||
textStyle: TextStyle,
|
||||
iconTint: ColorFilter,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(icon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(14.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceDeviceLabel(
|
||||
label: String?,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class WidgetConfig(
|
||||
val profileId: ProfileId? = null,
|
||||
val theme: WidgetTheme = WidgetTheme(),
|
||||
)
|
||||
@@ -47,19 +47,21 @@ class WidgetConfigurationViewModel @Inject constructor(
|
||||
|
||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
log(TAG) { "Invalid widget ID" }
|
||||
} else {
|
||||
widgetSettings.migrateLegacyConfigIfNeeded(widgetId, appWidgetManager.getAppWidgetOptions(widgetId))
|
||||
}
|
||||
}
|
||||
|
||||
private val selectedProfile = MutableStateFlow(widgetSettings.getWidgetProfile(widgetId))
|
||||
|
||||
private val initialTheme: WidgetTheme = run {
|
||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetTheme.DEFAULT
|
||||
WidgetTheme.fromBundle(appWidgetManager.getAppWidgetOptions(widgetId))
|
||||
private val initialConfig: WidgetConfig = run {
|
||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetConfig()
|
||||
widgetSettings.getWidgetConfig(widgetId)
|
||||
}
|
||||
|
||||
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialTheme) == null)
|
||||
private val selectedProfile = MutableStateFlow(initialConfig.profileId)
|
||||
|
||||
private val currentTheme = MutableStateFlow(initialTheme)
|
||||
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialConfig.theme) == null)
|
||||
|
||||
private val currentTheme = MutableStateFlow(initialConfig.theme)
|
||||
|
||||
private val visibleProfiles = deviceProfilesRepo.profiles.map { profiles ->
|
||||
if (isAncWidget) profiles.filter { it.model.features.hasAncControl } else profiles
|
||||
@@ -152,18 +154,16 @@ class WidgetConfigurationViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun confirmSelection() {
|
||||
val selectedProfile = selectedProfile.value
|
||||
if (selectedProfile != null) {
|
||||
log(TAG, INFO) { "confirmSelection(widgetId=$widgetId, selectedProfile=$selectedProfile)" }
|
||||
widgetSettings.saveWidgetProfile(widgetId, selectedProfile)
|
||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
log(TAG, INFO) { "confirmSelection: invalid widget ID, skipping save" }
|
||||
return
|
||||
}
|
||||
|
||||
// Save theme to AppWidgetOptions bundle
|
||||
val theme = currentTheme.value
|
||||
log(TAG, INFO) { "confirmSelection: saving theme=$theme" }
|
||||
val options = appWidgetManager.getAppWidgetOptions(widgetId)
|
||||
theme.toBundle(options)
|
||||
appWidgetManager.updateAppWidgetOptions(widgetId, options)
|
||||
val config = WidgetConfig(
|
||||
profileId = selectedProfile.value,
|
||||
theme = currentTheme.value,
|
||||
)
|
||||
log(TAG, INFO) { "confirmSelection(widgetId=$widgetId, config=$config)" }
|
||||
widgetSettings.saveWidgetConfig(widgetId, config)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal fun DeviceMonitor.widgetDeviceFlow(profileId: String): Flow<PodDevice?> = devices
|
||||
.map { devices -> devices.firstOrNull { it.profileId == profileId } }
|
||||
.distinctUntilChangedBy { it?.toWidgetKey() }
|
||||
@@ -0,0 +1,55 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
/**
|
||||
* Snapshot of the [PodDevice] fields that affect widget rendering. Used to
|
||||
* gate widget refreshes so RSSI / reliability / scan-counter / timestamp churn
|
||||
* doesn't cause a refresh on every BLE advertisement.
|
||||
*/
|
||||
internal data class WidgetDeviceKey(
|
||||
val profileId: String?,
|
||||
val profileLabel: String?,
|
||||
val model: PodModel,
|
||||
val batteryLeft: Float?,
|
||||
val batteryRight: Float?,
|
||||
val batteryCase: Float?,
|
||||
val batteryHeadset: Float?,
|
||||
val isLeftPodCharging: Boolean?,
|
||||
val isRightPodCharging: Boolean?,
|
||||
val isCaseCharging: Boolean?,
|
||||
val isHeadsetBeingCharged: Boolean?,
|
||||
val isLeftInEar: Boolean?,
|
||||
val isRightInEar: Boolean?,
|
||||
val isBeingWorn: Boolean?,
|
||||
val isAapConnected: Boolean,
|
||||
val isAapReady: Boolean,
|
||||
val ancMode: AapSetting.AncMode.Value?,
|
||||
val pendingAncMode: AapSetting.AncMode.Value?,
|
||||
val visibleAncModes: List<AapSetting.AncMode.Value>,
|
||||
)
|
||||
|
||||
internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
||||
profileId = profileId,
|
||||
profileLabel = label,
|
||||
model = model,
|
||||
batteryLeft = batteryLeft,
|
||||
batteryRight = batteryRight,
|
||||
batteryCase = batteryCase,
|
||||
batteryHeadset = batteryHeadset,
|
||||
isLeftPodCharging = isLeftPodCharging,
|
||||
isRightPodCharging = isRightPodCharging,
|
||||
isCaseCharging = isCaseCharging,
|
||||
isHeadsetBeingCharged = isHeadsetBeingCharged,
|
||||
isLeftInEar = isLeftInEar,
|
||||
isRightInEar = isRightInEar,
|
||||
isBeingWorn = isBeingWorn,
|
||||
isAapConnected = isAapConnected,
|
||||
isAapReady = isAapReady,
|
||||
ancMode = ancMode?.current,
|
||||
pendingAncMode = pendingAncMode,
|
||||
visibleAncModes = visibleAncModes,
|
||||
)
|
||||
@@ -1,9 +1,16 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import javax.inject.Inject
|
||||
@@ -16,9 +23,63 @@ class WidgetManager @Inject constructor(
|
||||
) {
|
||||
|
||||
suspend fun refreshWidgets() {
|
||||
log(TAG, VERBOSE) { "refreshWidgets()" }
|
||||
BatteryGlanceWidget().updateAll(context)
|
||||
AncGlanceWidget().updateAll(context)
|
||||
val manager = GlanceAppWidgetManager(context)
|
||||
refreshWidget(
|
||||
name = "battery",
|
||||
widget = BatteryGlanceWidget(),
|
||||
providerClass = BatteryGlanceWidget::class.java,
|
||||
receiverClass = WidgetProvider::class.java,
|
||||
manager = manager,
|
||||
)
|
||||
refreshWidget(
|
||||
name = "anc",
|
||||
widget = AncGlanceWidget(),
|
||||
providerClass = AncGlanceWidget::class.java,
|
||||
receiverClass = AncWidgetProvider::class.java,
|
||||
manager = manager,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T : GlanceAppWidget> refreshWidget(
|
||||
name: String,
|
||||
widget: T,
|
||||
providerClass: Class<T>,
|
||||
receiverClass: Class<*>,
|
||||
manager: GlanceAppWidgetManager,
|
||||
) {
|
||||
val ids = manager.getGlanceIds(providerClass).ifEmpty {
|
||||
val appWidgetIds = AppWidgetManager.getInstance(context)
|
||||
.getAppWidgetIds(ComponentName(context, receiverClass))
|
||||
.toList()
|
||||
if (appWidgetIds.isNotEmpty()) {
|
||||
log(TAG, WARN) { "refresh($name): Glance IDs empty, falling back to platform ids=$appWidgetIds" }
|
||||
}
|
||||
appWidgetIds.mapNotNull { appWidgetId ->
|
||||
runCatching { manager.getGlanceIdBy(appWidgetId) }
|
||||
.onFailure { error ->
|
||||
log(TAG, ERROR) { "refresh($name): failed to resolve widgetId=$appWidgetId: ${error.asLog()}" }
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
log(TAG, VERBOSE) { "refresh($name): ids=${ids.toAppWidgetIds(manager)}" }
|
||||
|
||||
ids.forEach { id ->
|
||||
runCatching { widget.update(context, id) }
|
||||
.onFailure { error ->
|
||||
log(TAG, ERROR) { "refresh($name): update failed for id=${id.toAppWidgetId(manager)}: ${error.asLog()}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<GlanceId>.toAppWidgetIds(manager: GlanceAppWidgetManager): List<Int> =
|
||||
map { it.toAppWidgetId(manager) }
|
||||
|
||||
private fun GlanceId.toAppWidgetId(manager: GlanceAppWidgetManager): Int = runCatching {
|
||||
manager.getAppWidgetId(this)
|
||||
}.getOrElse {
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -4,18 +4,33 @@ import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.R
|
||||
|
||||
enum class BatteryLayout {
|
||||
TINY_COLUMN,
|
||||
NARROW,
|
||||
WIDE;
|
||||
|
||||
companion object {
|
||||
fun forCells(widthCells: Int): BatteryLayout = when {
|
||||
widthCells <= 1 -> TINY_COLUMN
|
||||
widthCells >= 5 -> WIDE
|
||||
else -> NARROW
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class WidgetRenderState {
|
||||
abstract val theme: WidgetTheme
|
||||
@get:ColorInt abstract val resolvedBgColor: Int
|
||||
@get:ColorInt abstract val resolvedTextColor: Int
|
||||
@get:ColorInt abstract val resolvedIconColor: Int
|
||||
abstract val layout: BatteryLayout
|
||||
|
||||
data class DualPod(
|
||||
override val theme: WidgetTheme,
|
||||
@ColorInt override val resolvedBgColor: Int,
|
||||
@ColorInt override val resolvedTextColor: Int,
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
val isWide: Boolean,
|
||||
override val layout: BatteryLayout,
|
||||
val deviceLabel: String?,
|
||||
@DrawableRes val leftIcon: Int,
|
||||
val leftPercent: Float,
|
||||
@@ -35,6 +50,7 @@ sealed class WidgetRenderState {
|
||||
@ColorInt override val resolvedBgColor: Int,
|
||||
@ColorInt override val resolvedTextColor: Int,
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
override val layout: BatteryLayout,
|
||||
val deviceLabel: String?,
|
||||
@DrawableRes val headsetIcon: Int,
|
||||
val percent: Float,
|
||||
@@ -48,6 +64,7 @@ sealed class WidgetRenderState {
|
||||
@ColorInt override val resolvedBgColor: Int,
|
||||
@ColorInt override val resolvedTextColor: Int,
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
override val layout: BatteryLayout,
|
||||
val primaryText: String,
|
||||
val secondaryText: String? = null,
|
||||
) : WidgetRenderState()
|
||||
@@ -57,6 +74,7 @@ sealed class WidgetRenderState {
|
||||
@ColorInt override val resolvedBgColor: Int,
|
||||
@ColorInt override val resolvedTextColor: Int,
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
override val layout: BatteryLayout,
|
||||
) : WidgetRenderState()
|
||||
|
||||
companion object {
|
||||
@@ -65,13 +83,13 @@ sealed class WidgetRenderState {
|
||||
@ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
|
||||
@ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
|
||||
@ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
|
||||
isWide: Boolean = false,
|
||||
layout: BatteryLayout = BatteryLayout.NARROW,
|
||||
): DualPod = DualPod(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
isWide = isWide,
|
||||
layout = layout,
|
||||
deviceLabel = "My AirPods Pro",
|
||||
leftIcon = R.drawable.device_airpods_pro2_left,
|
||||
leftPercent = 0.85f,
|
||||
@@ -85,5 +103,25 @@ sealed class WidgetRenderState {
|
||||
casePercent = 1.0f,
|
||||
caseCharging = false,
|
||||
)
|
||||
|
||||
fun previewSinglePod(
|
||||
theme: WidgetTheme = WidgetTheme.DEFAULT,
|
||||
@ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
|
||||
@ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
|
||||
@ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
|
||||
layout: BatteryLayout = BatteryLayout.NARROW,
|
||||
): SinglePod = SinglePod(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
layout = layout,
|
||||
deviceLabel = "My AirPods Max",
|
||||
headsetIcon = R.drawable.device_airpods_max,
|
||||
percent = 0.72f,
|
||||
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
|
||||
charging = false,
|
||||
worn = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ object WidgetRenderStateMapper {
|
||||
isPro: Boolean,
|
||||
hasConfiguredProfile: Boolean,
|
||||
profileLabel: String?,
|
||||
isWide: Boolean = false,
|
||||
layout: BatteryLayout = BatteryLayout.NARROW,
|
||||
): WidgetRenderState {
|
||||
val bgColor = resolvedBgColor(context, theme)
|
||||
val textColor = resolvedTextColor(context, theme)
|
||||
@@ -30,6 +30,7 @@ object WidgetRenderStateMapper {
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
layout = layout,
|
||||
primaryText = context.getString(R.string.upgrade_capod_label),
|
||||
secondaryText = context.getString(R.string.upgrade_capod_description),
|
||||
)
|
||||
@@ -39,7 +40,7 @@ object WidgetRenderStateMapper {
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
isWide = isWide,
|
||||
layout = layout,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
leftIcon = device.leftPodIcon,
|
||||
leftPercent = device.batteryLeft.toBatteryFloat(),
|
||||
@@ -59,6 +60,7 @@ object WidgetRenderStateMapper {
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
layout = layout,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
headsetIcon = device.iconRes,
|
||||
percent = device.batteryHeadset.toBatteryFloat(),
|
||||
@@ -72,6 +74,7 @@ object WidgetRenderStateMapper {
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
layout = layout,
|
||||
primaryText = context.getString(R.string.pods_unknown_label),
|
||||
)
|
||||
|
||||
@@ -86,6 +89,7 @@ object WidgetRenderStateMapper {
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
layout = layout,
|
||||
primaryText = context.getString(messageRes),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.SharedPreferencesMigration
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
@@ -11,15 +12,19 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class WidgetSettings @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
@ApplicationContext private val context: Context,
|
||||
@SerializationCapod private val json: Json,
|
||||
) {
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(
|
||||
@@ -29,32 +34,76 @@ class WidgetSettings @Inject constructor(
|
||||
|
||||
private val dataStore: DataStore<Preferences> get() = context.dataStore
|
||||
|
||||
fun saveWidgetProfile(widgetId: Int, profileId: ProfileId) {
|
||||
log(TAG, VERBOSE) { "saveWidgetProfile(widgetId=$widgetId, profileId=$profileId)" }
|
||||
runBlocking {
|
||||
dataStore.edit { it[stringPreferencesKey(getWidgetProfileKey(widgetId))] = profileId }
|
||||
fun getWidgetConfig(widgetId: Int): WidgetConfig {
|
||||
val raw = runBlocking { dataStore.data.first()[configKey(widgetId)] }
|
||||
if (raw == null) {
|
||||
log(TAG, VERBOSE) { "getWidgetConfig(widgetId=$widgetId) = absent → default" }
|
||||
return WidgetConfig()
|
||||
}
|
||||
return try {
|
||||
json.decodeFromString<WidgetConfig>(raw).also {
|
||||
log(TAG, VERBOSE) { "getWidgetConfig(widgetId=$widgetId) = $it" }
|
||||
}
|
||||
} catch (e: SerializationException) {
|
||||
log(TAG) { "getWidgetConfig(widgetId=$widgetId): malformed JSON, returning default ($e)" }
|
||||
WidgetConfig()
|
||||
}
|
||||
}
|
||||
|
||||
fun getWidgetProfile(widgetId: Int): ProfileId? {
|
||||
val profileId = runBlocking {
|
||||
dataStore.data.first()[stringPreferencesKey(getWidgetProfileKey(widgetId))]
|
||||
fun saveWidgetConfig(widgetId: Int, config: WidgetConfig) {
|
||||
log(TAG, VERBOSE) { "saveWidgetConfig(widgetId=$widgetId, config=$config)" }
|
||||
val encoded = json.encodeToString(config)
|
||||
runBlocking {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[configKey(widgetId)] = encoded
|
||||
prefs.remove(legacyProfileKey(widgetId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot migration from the legacy storage layout (separate `widget_profile_<id>` DataStore key
|
||||
* + per-widget theme in `AppWidgetManager.getAppWidgetOptions()`) to the unified
|
||||
* `widget_config_<id>` JSON. Marker-guarded inside the same `dataStore.edit {}` block so
|
||||
* concurrent saves can't be overwritten.
|
||||
*
|
||||
* Pre-reboot the legacy options Bundle still carries the user's theme; post-reboot it does not
|
||||
* (Android only persists the standard `OPTION_APPWIDGET_*` keys).
|
||||
*/
|
||||
fun migrateLegacyConfigIfNeeded(widgetId: Int, legacyOptions: Bundle) {
|
||||
val legacyTheme = WidgetTheme.fromLegacyBundleOrNull(legacyOptions)
|
||||
runBlocking {
|
||||
dataStore.edit { prefs ->
|
||||
if (prefs[configKey(widgetId)] != null) return@edit
|
||||
val legacyProfileId = prefs[legacyProfileKey(widgetId)]
|
||||
if (legacyProfileId == null && legacyTheme == null) return@edit
|
||||
val migrated = WidgetConfig(
|
||||
profileId = legacyProfileId,
|
||||
theme = legacyTheme ?: WidgetTheme(),
|
||||
)
|
||||
log(TAG) { "migrateLegacyConfigIfNeeded(widgetId=$widgetId): writing $migrated" }
|
||||
prefs[configKey(widgetId)] = json.encodeToString(migrated)
|
||||
prefs.remove(legacyProfileKey(widgetId))
|
||||
}
|
||||
}
|
||||
log(TAG, VERBOSE) { "getWidgetProfile(widgetId=$widgetId) = $profileId" }
|
||||
return profileId
|
||||
}
|
||||
|
||||
fun removeWidget(widgetId: Int) {
|
||||
log(TAG, VERBOSE) { "removeWidget(widgetId=$widgetId)" }
|
||||
runBlocking {
|
||||
dataStore.edit { it.remove(stringPreferencesKey(getWidgetProfileKey(widgetId))) }
|
||||
dataStore.edit { prefs ->
|
||||
prefs.remove(configKey(widgetId))
|
||||
prefs.remove(legacyProfileKey(widgetId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getWidgetProfileKey(widgetId: Int): String = "$WIDGET_PROFILE_PREFIX$widgetId"
|
||||
private fun configKey(widgetId: Int) = stringPreferencesKey("$WIDGET_CONFIG_PREFIX$widgetId")
|
||||
private fun legacyProfileKey(widgetId: Int) = stringPreferencesKey("$LEGACY_WIDGET_PROFILE_PREFIX$widgetId")
|
||||
|
||||
companion object {
|
||||
private const val WIDGET_PROFILE_PREFIX = "widget_profile_"
|
||||
private const val WIDGET_CONFIG_PREFIX = "widget_config_"
|
||||
private const val LEGACY_WIDGET_PROFILE_PREFIX = "widget_profile_"
|
||||
private val TAG = logTag("Widget", "Settings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class WidgetTheme(
|
||||
@ColorInt val backgroundColor: Int? = null,
|
||||
@ColorInt val foregroundColor: Int? = null,
|
||||
@@ -36,7 +38,18 @@ data class WidgetTheme(
|
||||
|
||||
val DEFAULT = WidgetTheme()
|
||||
|
||||
fun fromBundle(bundle: Bundle): WidgetTheme {
|
||||
/**
|
||||
* Reads a [WidgetTheme] from the legacy `AppWidgetManager.getAppWidgetOptions()` Bundle.
|
||||
* Returns `null` if the Bundle holds none of the legacy keys — distinguishing "system reset
|
||||
* the Bundle to system-only keys" from "user picked Material You + 255".
|
||||
*/
|
||||
fun fromLegacyBundleOrNull(bundle: Bundle): WidgetTheme? {
|
||||
val hasLegacyKey = bundle.containsKey(KEY_THEME_MODE)
|
||||
|| bundle.containsKey(KEY_BG_ALPHA)
|
||||
|| bundle.containsKey(KEY_SHOW_LABEL)
|
||||
|| bundle.containsKey(KEY_CUSTOM_BG)
|
||||
|| bundle.containsKey(KEY_CUSTOM_FG)
|
||||
if (!hasLegacyKey) return null
|
||||
val mode = bundle.getString(KEY_THEME_MODE, MODE_MATERIAL_YOU)
|
||||
val showLabel = bundle.getBoolean(KEY_SHOW_LABEL, true)
|
||||
val alpha = bundle.getInt(KEY_BG_ALPHA, 255).coerceIn(0, 255)
|
||||
@@ -71,25 +84,4 @@ data class WidgetTheme(
|
||||
}
|
||||
}
|
||||
|
||||
fun toBundle(bundle: Bundle) {
|
||||
if (backgroundColor == null && foregroundColor == null) {
|
||||
bundle.putString(KEY_THEME_MODE, MODE_MATERIAL_YOU)
|
||||
bundle.remove(KEY_CUSTOM_BG)
|
||||
bundle.remove(KEY_CUSTOM_FG)
|
||||
} else {
|
||||
bundle.putString(KEY_THEME_MODE, MODE_CUSTOM)
|
||||
if (backgroundColor != null) {
|
||||
bundle.putInt(KEY_CUSTOM_BG, backgroundColor)
|
||||
} else {
|
||||
bundle.remove(KEY_CUSTOM_BG)
|
||||
}
|
||||
if (foregroundColor != null) {
|
||||
bundle.putInt(KEY_CUSTOM_FG, foregroundColor)
|
||||
} else {
|
||||
bundle.remove(KEY_CUSTOM_FG)
|
||||
}
|
||||
}
|
||||
bundle.putInt(KEY_BG_ALPHA, backgroundAlpha.coerceIn(0, 255))
|
||||
bundle.putBoolean(KEY_SHOW_LABEL, showDeviceLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ data class PodDevice(
|
||||
val model: PodModel get() = ble?.model ?: profileModel ?: cached?.model ?: PodModel.UNKNOWN
|
||||
/** Bonded BR/EDR address (from profile). Used for AAP commands. */
|
||||
val address: BluetoothAddress? get() = ble?.meta?.profile?.address ?: profileAddress ?: cached?.address
|
||||
/**
|
||||
* True when the matched profile has a selected paired Bluetooth device. Unlike [address],
|
||||
* this never falls back to the cache, so a profile whose paired device was removed still
|
||||
* reports `false` even while a stale cached address lingers.
|
||||
*/
|
||||
val hasSelectedPairedDevice: Boolean
|
||||
get() = (ble?.meta?.profile?.address ?: profileAddress) != null
|
||||
/** BLE scan address (RPA, rotates). */
|
||||
val bleAddress: BluetoothAddress? get() = ble?.address
|
||||
val identifier: BlePodSnapshot.Id? get() = ble?.identifier
|
||||
@@ -78,6 +85,7 @@ data class PodDevice(
|
||||
val hasEarDetection: Boolean get() = model.features.hasEarDetection
|
||||
val hasAncControl: Boolean get() = model.features.hasAncControl
|
||||
val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone
|
||||
val hasDynamicEndOfCharge: Boolean get() = model.features.hasDynamicEndOfCharge
|
||||
|
||||
// Signal / timing
|
||||
val seenLastAt: Instant?
|
||||
@@ -179,6 +187,15 @@ data class PodDevice(
|
||||
val isHeadsetBeingCharged: Boolean?
|
||||
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging
|
||||
|
||||
// Full per-slot charging state — AAP only (BLE + cache don't carry CHARGING_OPTIMIZED).
|
||||
// Null means "no live AAP reading", which lets callers avoid showing a stale Optimized chip
|
||||
// after the device went out of range. Existing isLeftCharging/etc. remain the Boolean
|
||||
// collapse of CHARGING + CHARGING_OPTIMIZED for everyone who just cares "is it charging".
|
||||
val leftPodChargingState: AapPodState.ChargingState? get() = aap?.leftChargingState
|
||||
val rightPodChargingState: AapPodState.ChargingState? get() = aap?.rightChargingState
|
||||
val caseChargingState: AapPodState.ChargingState? get() = aap?.caseChargingState
|
||||
val headsetChargingState: AapPodState.ChargingState? get() = aap?.headsetChargingState
|
||||
|
||||
// Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback.
|
||||
private val resolvedPrimaryPod: DualBlePodSnapshot.Pod?
|
||||
get() = aap?.aapPrimaryPod?.pod?.let { aapPod ->
|
||||
@@ -188,18 +205,27 @@ data class PodDevice(
|
||||
}
|
||||
} ?: (ble as? DualApplePods)?.primaryPod
|
||||
|
||||
private fun AapSetting.EarDetection.PodPlacement.isInEar(): Boolean =
|
||||
this == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
|
||||
private fun AapSetting.EarDetection.samePlacementInEarOrNull(): Boolean? =
|
||||
if (primaryPod == secondaryPod) primaryPod.isInEar() else null
|
||||
|
||||
// Ear detection — AAP preferred (lower latency), BLE fallback.
|
||||
// AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary.
|
||||
val isLeftInEar: Boolean?
|
||||
get() {
|
||||
val earDetection = aap?.aapEarDetection
|
||||
val primary = resolvedPrimaryPod
|
||||
if (earDetection != null && primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
|
||||
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
} else {
|
||||
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
if (earDetection != null) {
|
||||
if (primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
|
||||
earDetection.primaryPod.isInEar()
|
||||
} else {
|
||||
earDetection.secondaryPod.isInEar()
|
||||
}
|
||||
}
|
||||
earDetection.samePlacementInEarOrNull()?.let { return it }
|
||||
}
|
||||
return (ble as? HasEarDetectionDual)?.isLeftPodInEar
|
||||
}
|
||||
@@ -208,12 +234,15 @@ data class PodDevice(
|
||||
get() {
|
||||
val earDetection = aap?.aapEarDetection
|
||||
val primary = resolvedPrimaryPod
|
||||
if (earDetection != null && primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
|
||||
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
} else {
|
||||
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
if (earDetection != null) {
|
||||
if (primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
|
||||
earDetection.primaryPod.isInEar()
|
||||
} else {
|
||||
earDetection.secondaryPod.isInEar()
|
||||
}
|
||||
}
|
||||
earDetection.samePlacementInEarOrNull()?.let { return it }
|
||||
}
|
||||
return (ble as? HasEarDetectionDual)?.isRightPodInEar
|
||||
}
|
||||
@@ -330,13 +359,16 @@ data class PodDevice(
|
||||
val sleepDetection: AapSetting.SleepDetection?
|
||||
get() = aap?.setting()
|
||||
|
||||
val dynamicEndOfCharge: AapSetting.DynamicEndOfCharge?
|
||||
get() = aap?.setting()
|
||||
|
||||
val connectedDevices: AapSetting.ConnectedDevices?
|
||||
get() = aap?.setting()
|
||||
|
||||
val audioSource: AapSetting.AudioSource?
|
||||
get() = aap?.setting()
|
||||
|
||||
val eqBands: AapSetting.EqBands?
|
||||
val pmeConfig: AapSetting.PmeConfig?
|
||||
get() = aap?.setting()
|
||||
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
|
||||
@@ -45,6 +45,7 @@ import javax.inject.Singleton
|
||||
class BlePodMonitor @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val bleScanner: BleScanner,
|
||||
private val bleScanModeController: BleScanModeController,
|
||||
private val podFactory: PodFactory,
|
||||
private val timeSource: TimeSource,
|
||||
private val generalSettings: GeneralSettings,
|
||||
@@ -128,7 +129,7 @@ class BlePodMonitor @Inject constructor(
|
||||
)
|
||||
|
||||
private fun createBleScanner() = combine(
|
||||
generalSettings.scannerMode.flow,
|
||||
bleScanModeController.scannerMode,
|
||||
debugSettings.showUnfiltered.flow,
|
||||
generalSettings.isOffloadedBatchingDisabled.flow,
|
||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package eu.darken.capod.monitor.core.ble
|
||||
|
||||
import eu.darken.capod.common.AppForegroundState
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.replayingShare
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class BleScanModeController @Inject constructor(
|
||||
@AppScope appScope: CoroutineScope,
|
||||
appForegroundState: AppForegroundState,
|
||||
profilesRepo: DeviceProfilesRepo,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
) {
|
||||
|
||||
private val overrideCounts = MutableStateFlow<Map<ScannerMode, Int>>(emptyMap())
|
||||
private val activeOverride: Flow<ScannerMode?> = overrideCounts.map { it.topPriority() }
|
||||
|
||||
val scannerMode: Flow<ScannerMode> = combine(
|
||||
activeOverride,
|
||||
appForegroundState.isForeground,
|
||||
profilesRepo.profiles,
|
||||
bluetoothManager.connectedDevices
|
||||
.onStart { emit(emptyList()) }
|
||||
.catch {
|
||||
log(TAG, Logging.Priority.WARN) { "connectedDevices failed, treating as empty: $it" }
|
||||
emit(emptyList())
|
||||
},
|
||||
bluetoothManager.bondedDeviceAddresses,
|
||||
) { override, isForeground, profiles, connectedDevices, bondedAddresses ->
|
||||
resolveScannerMode(
|
||||
overrideMode = override,
|
||||
isForeground = isForeground,
|
||||
profileAddresses = profiles.mapNotNull { it.address }.toSet(),
|
||||
bondedAddresses = bondedAddresses,
|
||||
connectedAddresses = connectedDevices.map { it.address }.toSet(),
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { log(TAG) { "Effective scanner mode: $it" } }
|
||||
.setupCommonEventHandlers(TAG) { "scannerMode" }
|
||||
.replayingShare(appScope)
|
||||
|
||||
suspend fun <T> withTemporaryOverride(mode: ScannerMode, block: suspend () -> T): T {
|
||||
log(TAG) { "withTemporaryOverride($mode) acquire" }
|
||||
overrideCounts.update { it.adjust(mode, +1) }
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
overrideCounts.update { it.adjust(mode, -1) }
|
||||
log(TAG) { "withTemporaryOverride($mode) release" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<ScannerMode, Int>.adjust(mode: ScannerMode, delta: Int): Map<ScannerMode, Int> {
|
||||
val current = this[mode] ?: 0
|
||||
val next = current + delta
|
||||
return when {
|
||||
next <= 0 -> this - mode
|
||||
else -> this + (mode to next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<ScannerMode, Int>.topPriority(): ScannerMode? = OVERRIDE_PRIORITY.firstOrNull { containsKey(it) }
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Bluetooth", "ScannerMode")
|
||||
private val OVERRIDE_PRIORITY = listOf(ScannerMode.LOW_LATENCY, ScannerMode.BALANCED, ScannerMode.LOW_POWER)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resolveScannerMode(
|
||||
overrideMode: ScannerMode?,
|
||||
isForeground: Boolean,
|
||||
profileAddresses: Set<BluetoothAddress>,
|
||||
bondedAddresses: Set<BluetoothAddress>,
|
||||
connectedAddresses: Set<BluetoothAddress>,
|
||||
): ScannerMode {
|
||||
if (overrideMode != null) return overrideMode
|
||||
|
||||
val connectedProfileAddresses = profileAddresses.normalized()
|
||||
.intersect(bondedAddresses.normalized())
|
||||
.intersect(connectedAddresses.normalized())
|
||||
|
||||
return when {
|
||||
connectedProfileAddresses.isNotEmpty() -> ScannerMode.LOW_LATENCY
|
||||
isForeground -> ScannerMode.BALANCED
|
||||
else -> ScannerMode.LOW_POWER
|
||||
}
|
||||
}
|
||||
|
||||
private fun Iterable<BluetoothAddress>.normalized(): Set<BluetoothAddress> = this
|
||||
.map { it.uppercase(Locale.US) }
|
||||
.toSet()
|
||||
@@ -25,7 +25,12 @@ data class CachedDeviceState(
|
||||
@SerialName("firmwareVersion") val firmwareVersion: String? = null,
|
||||
@SerialName("leftEarbudSerial") val leftEarbudSerial: String? = null,
|
||||
@SerialName("rightEarbudSerial") val rightEarbudSerial: String? = null,
|
||||
@SerialName("buildNumber") val buildNumber: String? = null,
|
||||
/**
|
||||
* Formerly known as `buildNumber` — the Wireshark AAP dissector calls this
|
||||
* "Marketing Version". The `@SerialName("buildNumber")` preserves the wire
|
||||
* key so cache entries from older CAPod versions keep deserializing.
|
||||
*/
|
||||
@SerialName("buildNumber") val marketingVersion: String? = null,
|
||||
@Serializable(with = InstantEpochMillisSerializer::class)
|
||||
@SerialName("lastSeenAt") val lastSeenAt: Instant,
|
||||
) {
|
||||
@@ -40,7 +45,7 @@ data class CachedDeviceState(
|
||||
firmwareVersion = firmwareVersion ?: "",
|
||||
leftEarbudSerial = leftEarbudSerial,
|
||||
rightEarbudSerial = rightEarbudSerial,
|
||||
buildNumber = buildNumber,
|
||||
marketingVersion = marketingVersion,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ fun PodDevice.toCachedState(
|
||||
firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion,
|
||||
leftEarbudSerial = liveDeviceInfo?.leftEarbudSerial ?: existing?.leftEarbudSerial,
|
||||
rightEarbudSerial = liveDeviceInfo?.rightEarbudSerial ?: existing?.rightEarbudSerial,
|
||||
buildNumber = liveDeviceInfo?.buildNumber ?: existing?.buildNumber,
|
||||
marketingVersion = liveDeviceInfo?.marketingVersion ?: existing?.marketingVersion,
|
||||
lastSeenAt = seenLastAt ?: now,
|
||||
)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
@@ -35,11 +36,13 @@ import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.monitor.ui.MonitorNotifications
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
|
||||
import eu.darken.capod.reaction.core.playpause.PlayPause
|
||||
import eu.darken.capod.reaction.core.popup.PopUpReaction
|
||||
import eu.darken.capod.reaction.core.sleep.SleepReaction
|
||||
import eu.darken.capod.reaction.ui.popup.PopUpWindow
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -48,6 +51,7 @@ import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -73,6 +77,7 @@ class MonitorService : Service() {
|
||||
@Inject lateinit var playPause: PlayPause
|
||||
@Inject lateinit var autoConnect: AutoConnect
|
||||
@Inject lateinit var popUpReaction: PopUpReaction
|
||||
@Inject lateinit var sleepReaction: SleepReaction
|
||||
@Inject lateinit var popUpWindow: PopUpWindow
|
||||
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
||||
@Inject lateinit var aapConnectionManager: AapConnectionManager
|
||||
@@ -223,31 +228,19 @@ class MonitorService : Service() {
|
||||
profilesRepo.profiles,
|
||||
bluetoothManager.connectedDevices,
|
||||
aapConnectionManager.allStates,
|
||||
) { monitorMode, profiles, connectedDevices, aapStates ->
|
||||
listOf(monitorMode, profiles, connectedDevices, aapStates)
|
||||
) { mode, profiles, devices, aapStates ->
|
||||
buildMonitorModeState(mode, profiles, devices, aapStates)
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.setupCommonEventHandlers(TAG) { "MonitorMode" }
|
||||
.flatMapLatest { arguments ->
|
||||
val monitorMode = arguments[0] as MonitorMode
|
||||
.flatMapLatest { state ->
|
||||
log(TAG) { "Monitor mode: ${state.mode}" }
|
||||
log(TAG) { "connectedAddresses: ${state.connectedAddresses}" }
|
||||
log(TAG) { "knownAddresses: ${state.knownAddresses}" }
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val profiles = arguments[1] as List<DeviceProfile>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val devices = arguments[2] as Collection<BluetoothDevice2>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val aapStates = arguments[3] as Map<*, *>
|
||||
|
||||
val connectedAddresses = devices.map { it.address }.toSet()
|
||||
val knownAddresses = profiles.mapNotNull { it.address }.toSet()
|
||||
log(TAG) { "Monitor mode: $monitorMode" }
|
||||
log(TAG) { "connectedAddresses: $connectedAddresses" }
|
||||
log(TAG) { "knownAddresses: $knownAddresses" }
|
||||
|
||||
when (monitorMode) {
|
||||
when (state.mode) {
|
||||
MonitorMode.MANUAL -> flow<Unit> {
|
||||
monitorScope.coroutineContext.cancelChildren()
|
||||
}
|
||||
@@ -255,11 +248,11 @@ class MonitorService : Service() {
|
||||
MonitorMode.ALWAYS -> emptyFlow()
|
||||
MonitorMode.AUTOMATIC -> flow {
|
||||
when {
|
||||
profiles.isEmpty() && devices.isNotEmpty() -> {
|
||||
!state.hasProfiles && state.connectedAddresses.isNotEmpty() -> {
|
||||
log(TAG, WARN) { "Main device address not set, staying alive while any is connected" }
|
||||
}
|
||||
|
||||
knownAddresses.any { it in connectedAddresses } || aapStates.isNotEmpty() -> {
|
||||
state.knownAddresses.any { it in state.connectedAddresses } || state.hasAapSession -> {
|
||||
log(TAG) { "A device is connected, aborting any timeout." }
|
||||
}
|
||||
|
||||
@@ -302,6 +295,11 @@ class MonitorService : Service() {
|
||||
.catch { log(TAG, WARN) { "autoConnect failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
sleepReaction.monitor()
|
||||
.setupCommonEventHandlers(TAG) { "sleepReaction" }
|
||||
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
log(TAG, VERBOSE) { "Monitor job is active" }
|
||||
monitorJob.join()
|
||||
log(TAG, VERBOSE) { "Monitor job quit" }
|
||||
@@ -340,6 +338,27 @@ class MonitorService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
internal data class MonitorModeState(
|
||||
val mode: MonitorMode,
|
||||
val hasProfiles: Boolean,
|
||||
val knownAddresses: Set<BluetoothAddress>,
|
||||
val connectedAddresses: Set<BluetoothAddress>,
|
||||
val hasAapSession: Boolean,
|
||||
)
|
||||
|
||||
internal fun buildMonitorModeState(
|
||||
mode: MonitorMode,
|
||||
profiles: List<DeviceProfile>,
|
||||
devices: Collection<BluetoothDevice2>,
|
||||
aapStates: Map<BluetoothAddress, AapPodState>,
|
||||
): MonitorModeState = MonitorModeState(
|
||||
mode = mode,
|
||||
hasProfiles = profiles.isNotEmpty(),
|
||||
knownAddresses = profiles.mapNotNull { it.address }.toSet(),
|
||||
connectedAddresses = devices.map { it.address }.toSet(),
|
||||
hasAapSession = aapStates.isNotEmpty(),
|
||||
)
|
||||
|
||||
private data class NotificationDeviceKey(
|
||||
val profileId: String?,
|
||||
val label: String?,
|
||||
|
||||
@@ -232,6 +232,7 @@ enum class PodModel(
|
||||
hasAllowOffOption = true,
|
||||
hasStemConfig = true,
|
||||
hasSleepDetection = true,
|
||||
hasDynamicEndOfCharge = true,
|
||||
),
|
||||
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||
@@ -558,5 +559,12 @@ enum class PodModel(
|
||||
val hasAllowOffOption: Boolean = false,
|
||||
val hasStemConfig: Boolean = false,
|
||||
val hasSleepDetection: Boolean = false,
|
||||
/**
|
||||
* Apple's "Optimized Charge Limit" (AAP setting 0x3B). Distinct from the older
|
||||
* "Optimized Battery Charging" — that earlier feature isn't exposed as a toggleable
|
||||
* AAP setting. Enable on any model that's been confirmed (via capture) to push 0x3B
|
||||
* on connect and accept writes to it.
|
||||
*/
|
||||
val hasDynamicEndOfCharge: Boolean = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,10 +64,30 @@ class AapConnectionManager @Inject constructor(
|
||||
private val _stemPressEvents = MutableSharedFlow<Pair<BluetoothAddress, StemPressEvent>>(extraBufferCapacity = 32)
|
||||
val stemPressEvents: SharedFlow<Pair<BluetoothAddress, StemPressEvent>> = _stemPressEvents.asSharedFlow()
|
||||
|
||||
/**
|
||||
* Emits when a connected device fires a Sleep Detection update (AAP opcode 0x57).
|
||||
* Address-only — the opaque payload stays logged at engine level per the "never suppress
|
||||
* protocol logging" convention; downstream consumers (SleepReaction) only need the origin
|
||||
* address to decide whether to act.
|
||||
*/
|
||||
private val _sleepEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
|
||||
|
||||
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
|
||||
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||
val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow()
|
||||
|
||||
/**
|
||||
* Emits when any setting command failed verification on the device side. Unlike
|
||||
* [offRejectedEvents] this covers every rejected write (including the ANC-OFF case); UI
|
||||
* consumers filter by the command type they care about — e.g. the charge-cap toggle shows
|
||||
* a snackbar only for [AapCommand.SetDynamicEndOfCharge].
|
||||
*/
|
||||
private val _settingRejectedEvents =
|
||||
MutableSharedFlow<Pair<BluetoothAddress, AapCommand>>(extraBufferCapacity = 16)
|
||||
val settingRejectedEvents: SharedFlow<Pair<BluetoothAddress, AapCommand>> =
|
||||
_settingRejectedEvents.asSharedFlow()
|
||||
|
||||
fun deviceState(address: BluetoothAddress) = _allStates.map { it[address] }
|
||||
|
||||
suspend fun connect(
|
||||
@@ -112,6 +132,14 @@ class AapConnectionManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Forward sleep events from this connection (child coroutine). Address-only —
|
||||
// the opaque payload stays logged at the engine layer.
|
||||
launch {
|
||||
connection.sleepEvents.collect {
|
||||
_sleepEvents.tryEmit(address)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward OFF-rejection events from this connection (child coroutine)
|
||||
launch {
|
||||
connection.offRejected.collect {
|
||||
@@ -119,6 +147,14 @@ class AapConnectionManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Forward generic setting-rejection events from this connection (child coroutine).
|
||||
// Covers every rejected write — consumers filter by the command they care about.
|
||||
launch {
|
||||
connection.settingRejected.collect { command ->
|
||||
_settingRejectedEvents.tryEmit(address to command)
|
||||
}
|
||||
}
|
||||
|
||||
connection.state.collect { podState ->
|
||||
if (podState.connectionState == AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG) { "Connection to $address disconnected" }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package eu.darken.capod.pods.core.apple.aap
|
||||
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCaseInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import java.time.Instant
|
||||
@@ -16,6 +17,16 @@ data class AapPodState(
|
||||
val lastMessageAt: Instant? = null,
|
||||
val pendingAncMode: AapSetting.AncMode.Value? = null,
|
||||
val pendingSettingsCount: Int = 0,
|
||||
/**
|
||||
* 64-bit features bitmask from the Connect Response (packet type 0x0001).
|
||||
* Opaque — we don't yet know the bit-to-feature mapping. Logged for future
|
||||
* correlation work. Null until a Connect Response is seen.
|
||||
*/
|
||||
val negotiatedFeatures: ULong? = null,
|
||||
/** Status field from the Connect Response. 0 = success. Null before Connect Response. */
|
||||
val connectResponseStatus: Int? = null,
|
||||
/** Parsed Case Info (message type 0x23), if the device responded to a 0x22 probe. */
|
||||
val caseInfo: AapCaseInfo? = null,
|
||||
) {
|
||||
inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T
|
||||
|
||||
@@ -42,6 +53,13 @@ data class AapPodState(
|
||||
val batteryCase: Float? get() = batteries[BatteryType.CASE]?.percent
|
||||
val batteryHeadset: Float? get() = batteries[BatteryType.SINGLE]?.percent
|
||||
|
||||
// Raw charging state per slot — the full enum, not collapsed to a Boolean. Lets the UI
|
||||
// distinguish CHARGING_OPTIMIZED ("Optimized Charge Limit in effect") from plain CHARGING.
|
||||
val leftChargingState: ChargingState? get() = batteries[BatteryType.LEFT]?.charging
|
||||
val rightChargingState: ChargingState? get() = batteries[BatteryType.RIGHT]?.charging
|
||||
val caseChargingState: ChargingState? get() = batteries[BatteryType.CASE]?.charging
|
||||
val headsetChargingState: ChargingState? get() = batteries[BatteryType.SINGLE]?.charging
|
||||
|
||||
// Charging state from AAP battery
|
||||
val isLeftCharging: Boolean?
|
||||
get() = batteries[BatteryType.LEFT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
|
||||
|
||||
@@ -12,7 +12,8 @@ import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -45,7 +46,9 @@ internal class AapConnection(
|
||||
val state: StateFlow<AapPodState> get() = engine.state
|
||||
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
|
||||
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
|
||||
val sleepEvents: SharedFlow<AapSleepEvent> get() = engine.sleepEvents
|
||||
val offRejected: SharedFlow<Unit> get() = engine.offRejected
|
||||
val settingRejected: SharedFlow<AapCommand> get() = engine.settingRejected
|
||||
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var readerJob: Job? = null
|
||||
@@ -143,11 +146,36 @@ internal class AapConnection(
|
||||
break
|
||||
}
|
||||
|
||||
// L2CAP SEQPACKET: each read() returns exactly one complete message
|
||||
// L2CAP SEQPACKET: each read() returns exactly one complete frame
|
||||
val raw = buf.copyOfRange(0, len)
|
||||
val message = AapMessage.parse(raw)
|
||||
if (message != null) {
|
||||
engine.processMessage(message)
|
||||
val packet = AapPacket.parse(raw) ?: continue
|
||||
|
||||
when (packet) {
|
||||
is AapPacket.Message -> engine.processMessage(packet)
|
||||
is AapPacket.ConnectResponse -> {
|
||||
engine.processConnectResponse(packet)
|
||||
if (packet.status == 0) dispatchCaseInfoProbe()
|
||||
}
|
||||
is AapPacket.Disconnect -> {
|
||||
log(TAG, Logging.Priority.INFO) {
|
||||
"Disconnect received: service=0x${"%04X".format(packet.service)} status=0x${"%04X".format(packet.status)}"
|
||||
}
|
||||
}
|
||||
is AapPacket.DisconnectResponse -> {
|
||||
log(TAG) { "DisconnectResponse received: service=0x${"%04X".format(packet.service)}" }
|
||||
}
|
||||
is AapPacket.Connect -> {
|
||||
// Unexpected — we're the source, not the peer. Log and ignore.
|
||||
log(TAG, Logging.Priority.WARN) {
|
||||
"Unexpected Connect packet from peer: service=0x${"%04X".format(packet.service)}"
|
||||
}
|
||||
}
|
||||
is AapPacket.Unknown -> {
|
||||
val hex = packet.raw.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, Logging.Priority.INFO) {
|
||||
"Unknown AAP packet type=0x${"%04X".format(packet.packetType)} len=${packet.raw.size} raw=$hex"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
@@ -158,6 +186,31 @@ internal class AapConnection(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget Case Info request (message type 0x22). Sent after a
|
||||
* successful Connect Response. Not an AapCommand — bypasses the outbound
|
||||
* queue, ear-gating, and pending-settings counter. If the device doesn't
|
||||
* reply, nothing happens; the next connect attempt re-probes.
|
||||
*/
|
||||
private suspend fun dispatchCaseInfoProbe() {
|
||||
val bytes = profile.encodeCaseInfoRequest() ?: return
|
||||
try {
|
||||
writeMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
val sock = socket ?: return@withContext
|
||||
sock.outputStream.write(bytes)
|
||||
sock.outputStream.flush()
|
||||
val hex = bytes.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, Logging.Priority.VERBOSE) { "SEND CaseInfoProbe len=${bytes.size} raw=$hex" }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Non-critical: the probe is fire-and-forget; a send failure just means
|
||||
// we don't get Case Info this session. No retry, no state change.
|
||||
log(TAG, Logging.Priority.WARN) { "CaseInfoProbe send failed: $e" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupSocket() {
|
||||
try {
|
||||
socket?.close()
|
||||
|
||||
+70
-29
@@ -4,47 +4,88 @@ import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
|
||||
/**
|
||||
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for
|
||||
* issue #173 engraving discovery logging.
|
||||
* Diagnostic-only segmentation of a 0x1D INFORMATION payload, used for issue #173
|
||||
* engraving discovery logging.
|
||||
*
|
||||
* Schema matches the Wireshark AAP dissector's INFORMATION message:
|
||||
* - Segments 0-10 are NUL-delimited UTF-8 strings.
|
||||
* - Segments 11 and 12 are fixed 17-byte UUIDs (may contain 0x00 internally —
|
||||
* splitting on NUL here would corrupt the offsets for every later segment).
|
||||
* - Segments 13 and 14 are NUL-delimited ASCII-decimal timestamps.
|
||||
*
|
||||
* Payloads that truncate early just produce fewer segments — the helper stays
|
||||
* best-effort and never throws.
|
||||
*/
|
||||
internal object AapDeviceInfoDiagnostics {
|
||||
|
||||
private const val UUID_LEN = 17
|
||||
private const val LAST_STRING_SEGMENT = 10 // after this the UUID blob starts
|
||||
|
||||
fun describeSegments(payload: ByteArray): List<DeviceInfoSegment> {
|
||||
var start = 0
|
||||
while (start < payload.size) {
|
||||
val b = payload[start].toInt() and 0xFF
|
||||
if (b in 0x20..0x7E) break
|
||||
start++
|
||||
}
|
||||
if (start >= payload.size) return emptyList()
|
||||
var offset = skipBinaryHeader(payload)
|
||||
if (offset >= payload.size) return emptyList()
|
||||
|
||||
val segments = mutableListOf<DeviceInfoSegment>()
|
||||
var segIndex = 0
|
||||
var i = start
|
||||
while (i < payload.size) {
|
||||
while (i < payload.size && payload[i] == 0x00.toByte()) i++
|
||||
if (i >= payload.size) break
|
||||
|
||||
val segStart = i
|
||||
while (i < payload.size && payload[i] != 0x00.toByte()) i++
|
||||
val segBytes = payload.copyOfRange(segStart, i)
|
||||
|
||||
val utf8: String? = try {
|
||||
Charsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(segBytes))
|
||||
.toString()
|
||||
} catch (_: CharacterCodingException) {
|
||||
null
|
||||
}
|
||||
val hex = segBytes.joinToString("") { "%02X".format(it) }
|
||||
|
||||
segments += DeviceInfoSegment(segIndex, segStart, segBytes.size, utf8, hex)
|
||||
// Segments 0..10 — NUL-delimited UTF-8 strings
|
||||
while (segIndex <= LAST_STRING_SEGMENT && offset < payload.size) {
|
||||
while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++
|
||||
if (offset >= payload.size) break
|
||||
val segStart = offset
|
||||
while (offset < payload.size && payload[offset] != 0x00.toByte()) offset++
|
||||
segments += buildSegment(segIndex, segStart, payload.copyOfRange(segStart, offset))
|
||||
segIndex++
|
||||
}
|
||||
|
||||
// Skip the NUL terminator of segment 10 before the UUID blob.
|
||||
while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++
|
||||
|
||||
// Segments 11 and 12 — fixed 17-byte UUIDs, read verbatim.
|
||||
for (targetIdx in 11..12) {
|
||||
if (offset + UUID_LEN > payload.size) break
|
||||
val segBytes = payload.copyOfRange(offset, offset + UUID_LEN)
|
||||
segments += buildSegment(targetIdx, offset, segBytes)
|
||||
offset += UUID_LEN
|
||||
segIndex = targetIdx + 1
|
||||
}
|
||||
|
||||
// Segments 13+ — NUL-delimited timestamps / any trailing strings.
|
||||
while (offset < payload.size) {
|
||||
while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++
|
||||
if (offset >= payload.size) break
|
||||
val segStart = offset
|
||||
while (offset < payload.size && payload[offset] != 0x00.toByte()) offset++
|
||||
segments += buildSegment(segIndex, segStart, payload.copyOfRange(segStart, offset))
|
||||
segIndex++
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
private fun skipBinaryHeader(payload: ByteArray): Int {
|
||||
var i = 0
|
||||
while (i < payload.size) {
|
||||
val b = payload[i].toInt() and 0xFF
|
||||
if (b in 0x20..0x7E) return i
|
||||
i++
|
||||
}
|
||||
return payload.size
|
||||
}
|
||||
|
||||
private fun buildSegment(index: Int, offset: Int, bytes: ByteArray): DeviceInfoSegment {
|
||||
val utf8: String? = try {
|
||||
Charsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes))
|
||||
.toString()
|
||||
} catch (_: CharacterCodingException) {
|
||||
null
|
||||
}
|
||||
val hex = bytes.joinToString("") { "%02X".format(it) }
|
||||
return DeviceInfoSegment(index, offset, bytes.size, utf8, hex)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class DeviceInfoSegment(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.engine
|
||||
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCaseInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDynamicEndOfChargeEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlin.reflect.KClass
|
||||
@@ -14,6 +17,9 @@ internal sealed interface AapInboundUpdate {
|
||||
data class Battery(val batteries: Map<AapPodState.BatteryType, AapPodState.Battery>) : AapInboundUpdate
|
||||
data class PrivateKeys(val result: KeyExchangeResult) : AapInboundUpdate
|
||||
data class DeviceInfo(val info: AapDeviceInfo) : AapInboundUpdate
|
||||
data class CaseInfo(val info: AapCaseInfo) : AapInboundUpdate
|
||||
data class SleepEvent(val event: AapSleepEvent) : AapInboundUpdate
|
||||
data class DynamicEndOfChargeEvent(val event: AapDynamicEndOfChargeEvent) : AapInboundUpdate
|
||||
data class Setting(val key: KClass<out AapSetting>, val value: AapSetting) : AapInboundUpdate
|
||||
}
|
||||
|
||||
@@ -25,6 +31,11 @@ internal class AapInboundInterpreter(
|
||||
profile.decodeBattery(message)?.let { return AapInboundUpdate.Battery(it) }
|
||||
profile.decodePrivateKeyResponse(message)?.let { return AapInboundUpdate.PrivateKeys(it) }
|
||||
profile.decodeDeviceInfo(message)?.let { return AapInboundUpdate.DeviceInfo(it) }
|
||||
profile.decodeCaseInfo(message)?.let { return AapInboundUpdate.CaseInfo(it) }
|
||||
profile.decodeSleepEvent(message)?.let { return AapInboundUpdate.SleepEvent(it) }
|
||||
profile.decodeDynamicEndOfChargeEvent(message)?.let {
|
||||
return AapInboundUpdate.DynamicEndOfChargeEvent(it)
|
||||
}
|
||||
profile.decodeSetting(message)?.let { (key, value) ->
|
||||
return AapInboundUpdate.Setting(key, value)
|
||||
}
|
||||
|
||||
+5
-1
@@ -35,7 +35,11 @@ internal class AapOutboundController(
|
||||
runtimeState: OutboundRuntimeState,
|
||||
command: AapCommand,
|
||||
): OutboundDecision {
|
||||
if (command !is AapCommand.SetDeviceName) {
|
||||
// SetDeviceName and SetDynamicEndOfCharge bypass ear-gating:
|
||||
// - Rename is a user-initiated metadata change, independent of wear state.
|
||||
// - Charge cap (setting 0x3B) is toggled while pods sit in the closed case; queueing
|
||||
// it until worn would make the toggle look broken for its main use case.
|
||||
if (command !is AapCommand.SetDeviceName && command !is AapCommand.SetDynamicEndOfCharge) {
|
||||
val earDetection = podState.setting<AapSetting.EarDetection>()
|
||||
if (earDetection != null && !earDetection.isEitherPodInEar) {
|
||||
val result = coordinator.enqueue(runtimeState.pendingCommands, command, podState)
|
||||
|
||||
@@ -10,7 +10,10 @@ import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessageType
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -47,10 +50,24 @@ internal class AapSessionEngine(
|
||||
MutableSharedFlow<StemPressEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val stemPressEvents: SharedFlow<StemPressEvent> = _stemPressEvents.asSharedFlow()
|
||||
|
||||
private val _sleepEvents =
|
||||
MutableSharedFlow<AapSleepEvent>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val sleepEvents: SharedFlow<AapSleepEvent> = _sleepEvents.asSharedFlow()
|
||||
|
||||
private val _offRejected =
|
||||
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow()
|
||||
|
||||
/**
|
||||
* Fires whenever a write command fails verification after the coordinator's single retry —
|
||||
* i.e. the device neither echoed the expected state nor retried into a matching one.
|
||||
* Separate from [offRejected] which is specialised for the ANC-OFF UX path. Consumers filter
|
||||
* by the command type they care about (e.g. the charge-cap toggle shows a snackbar).
|
||||
*/
|
||||
private val _settingRejected =
|
||||
MutableSharedFlow<AapCommand>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val settingRejected: SharedFlow<AapCommand> = _settingRejected.asSharedFlow()
|
||||
|
||||
private val hidTracker = HidTracker { msg -> log(TAG) { msg } }
|
||||
private val inboundInterpreter = AapInboundInterpreter(profile)
|
||||
private val ancController = AapAncController()
|
||||
@@ -88,6 +105,21 @@ internal class AapSessionEngine(
|
||||
dispatch(AapEngineEvent.MessageReceived(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Connect Response (packet type 0x0001) from the peer. Stores
|
||||
* the 64-bit features bitmask in [AapPodState.negotiatedFeatures] for
|
||||
* future correlation work, but does NOT drive state transitions itself —
|
||||
* the first subsequent Message packet triggers HANDSHAKING → READY via
|
||||
* the existing logic.
|
||||
*
|
||||
* On non-zero status we log an error and skip the features update; the
|
||||
* engine stays in HANDSHAKING (no probes should fire until the session
|
||||
* succeeds).
|
||||
*/
|
||||
fun processConnectResponse(packet: AapPacket.ConnectResponse) {
|
||||
dispatch(AapEngineEvent.ConnectResponseReceived(packet))
|
||||
}
|
||||
|
||||
private fun dispatch(event: AapEngineEvent) {
|
||||
when (event) {
|
||||
is AapEngineEvent.SessionStarted -> {
|
||||
@@ -112,13 +144,36 @@ internal class AapSessionEngine(
|
||||
}
|
||||
|
||||
is AapEngineEvent.MessageReceived -> handleMessageReceived(event.message)
|
||||
is AapEngineEvent.ConnectResponseReceived -> handleConnectResponse(event.packet)
|
||||
is AapEngineEvent.InboundUpdateDecoded -> handleInboundUpdate(event.update)
|
||||
is AapEngineEvent.TimerFired -> handleTimerFired(event.key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConnectResponse(packet: AapPacket.ConnectResponse) {
|
||||
if (packet.status != 0) {
|
||||
log(TAG, ERROR) {
|
||||
"ConnectResponse failed: status=0x${"%04X".format(packet.status)} " +
|
||||
"major=${packet.major} minor=${packet.minor} " +
|
||||
"features=0x${"%016X".format(packet.features.toLong())}"
|
||||
}
|
||||
_state.value = _state.value.copy(connectResponseStatus = packet.status)
|
||||
return
|
||||
}
|
||||
|
||||
log(TAG, INFO) {
|
||||
"ConnectResponse OK: major=${packet.major} minor=${packet.minor} " +
|
||||
"features=0x${"%016X".format(packet.features.toLong())}"
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
negotiatedFeatures = packet.features,
|
||||
connectResponseStatus = packet.status,
|
||||
lastMessageAt = timeSource.now(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleMessageReceived(message: AapMessage) {
|
||||
if (message.commandType != CMD_HID_DESCRIPTOR) {
|
||||
if (message.commandType != AapMessageType.BUDDY_COMMAND.value) {
|
||||
val hex = message.raw.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, VERBOSE) {
|
||||
"MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex"
|
||||
@@ -127,7 +182,7 @@ internal class AapSessionEngine(
|
||||
}
|
||||
|
||||
if (!runtimeState.handshakeResponseReceived &&
|
||||
message.commandType != CMD_SETTING &&
|
||||
message.commandType != AapMessageType.CONTROL.value &&
|
||||
_state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING
|
||||
) {
|
||||
runtimeState = runtimeState.copy(handshakeResponseReceived = true)
|
||||
@@ -135,13 +190,13 @@ internal class AapSessionEngine(
|
||||
log(TAG) { "Connection READY" }
|
||||
}
|
||||
|
||||
if (message.commandType == CMD_HID_DESCRIPTOR) {
|
||||
if (message.commandType == AapMessageType.BUDDY_COMMAND.value) {
|
||||
hidTracker.consume(message.payload)
|
||||
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
|
||||
return
|
||||
}
|
||||
|
||||
if (message.commandType == CMD_DEVICE_INFO) {
|
||||
if (message.commandType == AapMessageType.INFORMATION.value) {
|
||||
logDeviceInfoDiagnostics(message.payload)
|
||||
}
|
||||
|
||||
@@ -186,6 +241,25 @@ internal class AapSessionEngine(
|
||||
log(TAG) { "Device info: ${update.info.name} (${update.info.modelNumber})" }
|
||||
}
|
||||
|
||||
is AapInboundUpdate.CaseInfo -> {
|
||||
_state.value = _state.value.copy(caseInfo = update.info, lastMessageAt = timeSource.now())
|
||||
val hex = update.info.rawPayload.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, INFO) { "Case info: ${update.info.rawPayload.size}B payload=[$hex]" }
|
||||
}
|
||||
|
||||
is AapInboundUpdate.SleepEvent -> {
|
||||
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
|
||||
val hex = update.event.rawPayload.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, INFO) { "Sleep event: ${update.event.rawPayload.size}B payload=[$hex]" }
|
||||
_sleepEvents.tryEmit(update.event)
|
||||
}
|
||||
|
||||
is AapInboundUpdate.DynamicEndOfChargeEvent -> {
|
||||
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
|
||||
val hex = update.event.rawPayload.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, INFO) { "Dynamic EoC event: ${update.event.rawPayload.size}B payload=[$hex]" }
|
||||
}
|
||||
|
||||
is AapInboundUpdate.Setting -> handleSettingUpdate(update.key, update.value)
|
||||
}
|
||||
}
|
||||
@@ -361,10 +435,12 @@ internal class AapSessionEngine(
|
||||
}
|
||||
|
||||
private fun handleRejectedCommand(command: AapCommand?) {
|
||||
if (command == null) return
|
||||
if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) {
|
||||
applyAncDecision(ancController.onOffRejected(_state.value, runtimeState.anc))
|
||||
_offRejected.tryEmit(Unit)
|
||||
}
|
||||
_settingRejected.tryEmit(command)
|
||||
}
|
||||
|
||||
private fun scheduleTimer(key: EngineTimerKey, delayMs: Long) {
|
||||
@@ -411,20 +487,24 @@ internal class AapSessionEngine(
|
||||
"DeviceInfoDump #173: payload=${payload.size} bytes, segments=${segments.size}"
|
||||
}
|
||||
segments.forEach { seg ->
|
||||
// Labels per the Wireshark AAP dissector. The segmenter knows the schema —
|
||||
// segments 11 and 12 are fixed 17-byte UUIDs.
|
||||
val label = when (seg.index) {
|
||||
0 -> "name"
|
||||
1 -> "modelNumber"
|
||||
2 -> "manufacturer"
|
||||
3 -> "serialNumber"
|
||||
4 -> "firmwareVersion"
|
||||
5 -> "firmwareVersionDup"
|
||||
6 -> "protocolVersion"
|
||||
7 -> "updaterAppId"
|
||||
5 -> "firmwareVersionPending"
|
||||
6 -> "hardwareVersion"
|
||||
7 -> "eaProtocolName"
|
||||
8 -> "leftEarbudSerial"
|
||||
9 -> "rightEarbudSerial"
|
||||
10 -> "buildNumber"
|
||||
11 -> "encryptedBlob"
|
||||
12 -> "timestamp"
|
||||
10 -> "marketingVersion"
|
||||
11 -> "leftEarbudUuid (17 bytes fixed)"
|
||||
12 -> "rightEarbudUuid (17 bytes fixed)"
|
||||
13 -> "leftEarbudFirstPaired"
|
||||
14 -> "rightEarbudFirstPaired"
|
||||
else -> "unknown"
|
||||
}
|
||||
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
|
||||
@@ -438,7 +518,7 @@ internal class AapSessionEngine(
|
||||
val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) }
|
||||
val sendInfo = currentSendDebugInfo()
|
||||
|
||||
if (message.commandType == CMD_SETTING && message.payload.size >= 2) {
|
||||
if (message.commandType == AapMessageType.CONTROL.value && message.payload.size >= 2) {
|
||||
val settingId = message.payload[0].toInt() and 0xFF
|
||||
val value = message.payload[1].toInt() and 0xFF
|
||||
val boolHint = value.appleBoolHint()
|
||||
@@ -461,7 +541,7 @@ internal class AapSessionEngine(
|
||||
return
|
||||
}
|
||||
|
||||
if (message.commandType == CMD_CONNECTED_DEVICE && message.payload.size >= 6) {
|
||||
if (message.commandType == AapMessageType.MAC_ADDRESS.value && message.payload.size >= 6) {
|
||||
val macRaw = message.payload.formatMac(reverse = false)
|
||||
val macReversed = message.payload.formatMac(reverse = true)
|
||||
val tailHex = if (message.payload.size > 6) {
|
||||
@@ -472,7 +552,7 @@ internal class AapSessionEngine(
|
||||
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
|
||||
log(TAG, VERBOSE) {
|
||||
buildString {
|
||||
append("Known cmd=0x000C")
|
||||
append("Known cmd=0x000C (${AapMessageType.MAC_ADDRESS.wiresharkName})")
|
||||
append(" payload=${message.payload.size}B")
|
||||
append(" macRaw=$macRaw macReversed=$macReversed")
|
||||
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
|
||||
@@ -484,26 +564,56 @@ internal class AapSessionEngine(
|
||||
|
||||
if (message.commandType in KNOWN_NON_SETTINGS_COMMANDS) {
|
||||
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
|
||||
val namedType = AapMessageType.byValue(message.commandType)
|
||||
val nameLabel = namedType?.let { " (${it.wiresharkName})" } ?: ""
|
||||
log(TAG, VERBOSE) {
|
||||
"Known cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}"
|
||||
"Known cmd=0x${"%04X".format(message.commandType)}$nameLabel payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val namedType = AapMessageType.byValue(message.commandType)
|
||||
val nameLabel = namedType?.let { " (${it.wiresharkName})" } ?: " (unknown)"
|
||||
log(TAG, INFO) {
|
||||
"Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}"
|
||||
"Unhandled cmd=0x${"%04X".format(message.commandType)}$nameLabel payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("AAP", "Engine")
|
||||
private const val CMD_SETTING = 0x0009
|
||||
private const val CMD_CONNECTED_DEVICE = 0x000C
|
||||
private const val CMD_DEVICE_INFO = 0x001D
|
||||
private const val CMD_HID_DESCRIPTOR = 0x0017
|
||||
|
||||
private val KNOWN_NON_SETTINGS_COMMANDS = setOf(
|
||||
0x0000, 0x0002, 0x002B, 0x004E, 0x0052, 0x0055, 0x0057,
|
||||
/**
|
||||
* Opcodes we see on-wire but don't model as a domain update. Decoded only
|
||||
* enough to refresh `lastMessageAt` (freshness feeds the AAP quality boost
|
||||
* in PodDevice.computeAapBoost). Add newly-catalogued push-only opcodes here
|
||||
* when they appear in captures so logs get a named label AND freshness stays
|
||||
* intact.
|
||||
*/
|
||||
private val KNOWN_NON_SETTINGS_COMMANDS: Set<Int> = setOf(
|
||||
0x0000, // Connect (shouldn't reach here but historically observed)
|
||||
AapMessageType.CAPABILITIES.value, // 0x0002
|
||||
AapMessageType.DEVICE_LIST.value, // 0x000B
|
||||
AapMessageType.TRIANGLE_STATUS_REQUEST.value, // 0x0015
|
||||
AapMessageType.MAGNET_LINK.value, // 0x0016
|
||||
AapMessageType.TIMESTAMP.value, // 0x001B
|
||||
AapMessageType.UNKNOWN_0X21.value, // 0x0021
|
||||
AapMessageType.CASE_INFO.value, // 0x0023 (handled via decoder later; still refresh)
|
||||
AapMessageType.GYRO_INFO.value, // 0x0028
|
||||
AapMessageType.STREAM_STATE_INFO.value, // 0x002B
|
||||
AapMessageType.GAPA_CHALLENGE.value, // 0x002C
|
||||
AapMessageType.UNKNOWN_0X40.value, // 0x0040
|
||||
AapMessageType.ADAPTIVE_VOLUME_MESSAGE.value, // 0x004C
|
||||
AapMessageType.SOURCE_FEATURE_CAPABILITIES.value, // 0x004D
|
||||
AapMessageType.FEATURE_PROX_CARD_STATUS_UPDATE.value, // 0x004E
|
||||
AapMessageType.UARP_DATA.value, // 0x004F
|
||||
AapMessageType.UNKNOWN_0X50.value, // 0x0050
|
||||
AapMessageType.SOURCE_CONTEXT.value, // 0x0052
|
||||
AapMessageType.SET_BAND_EDGES.value, // 0x0054 RF band gating (push-only; see AapMessageType kdoc)
|
||||
AapMessageType.UNKNOWN_0X55.value, // 0x0055
|
||||
AapMessageType.SLEEP_DETECTION_UPDATE.value, // 0x0057
|
||||
AapMessageType.UNKNOWN_0X58.value, // 0x0058
|
||||
AapMessageType.DYNAMIC_END_OF_CHARGE.value, // 0x0059
|
||||
AapMessageType.PERSONAL_TRANSLATION.value, // 0x0060
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -517,6 +627,7 @@ internal sealed interface AapEngineEvent {
|
||||
data object HandshakeSent : AapEngineEvent
|
||||
data object ResetRequested : AapEngineEvent
|
||||
data class MessageReceived(val message: AapMessage) : AapEngineEvent
|
||||
data class ConnectResponseReceived(val packet: AapPacket.ConnectResponse) : AapEngineEvent
|
||||
data class InboundUpdateDecoded(val update: AapInboundUpdate) : AapEngineEvent
|
||||
data class TimerFired(val key: EngineTimerKey) : AapEngineEvent
|
||||
}
|
||||
|
||||
@@ -172,6 +172,10 @@ internal class AapSettingsCoordinator(
|
||||
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled)
|
||||
}
|
||||
|
||||
is AapCommand.SetDynamicEndOfCharge -> {
|
||||
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = command.enabled)
|
||||
}
|
||||
|
||||
is AapCommand.SetDeviceName -> {
|
||||
val currentInfo = baseState.deviceInfo ?: return null
|
||||
return baseState.copy(
|
||||
@@ -205,6 +209,7 @@ internal class AapSettingsCoordinator(
|
||||
is AapCommand.SetAllowOffOption -> { s -> s.setting<AapSetting.AllowOffOption>()?.enabled == command.enabled }
|
||||
is AapCommand.SetStemConfig -> { s -> s.setting<AapSetting.StemConfig>()?.claimedPressMask == command.claimedPressMask }
|
||||
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
|
||||
is AapCommand.SetDynamicEndOfCharge -> { s -> s.setting<AapSetting.DynamicEndOfCharge>()?.enabled == command.enabled }
|
||||
is AapCommand.SetDeviceName -> null
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,12 @@ internal class HidTracker(private val log: (String) -> Unit) {
|
||||
log("HID: terminator (${type.payloadSize}B)")
|
||||
}
|
||||
|
||||
is HidFrameType.ServiceInfo -> {
|
||||
flush()
|
||||
val tokens = type.asciiTokens.joinToString(", ")
|
||||
log("HID: service info tokens=[$tokens] (${type.payloadSize}B)")
|
||||
}
|
||||
|
||||
is HidFrameType.Other -> {
|
||||
flush()
|
||||
val hex = payload.joinToString(" ") { "%02X".format(it) }
|
||||
@@ -70,10 +76,25 @@ internal class HidTracker(private val log: (String) -> Unit) {
|
||||
data class ServiceDirectory(val services: List<String>) : HidFrameType()
|
||||
data class Descriptor(val phase: Int, val fill: Int) : HidFrameType()
|
||||
data class Terminator(val payloadSize: Int) : HidFrameType()
|
||||
|
||||
/**
|
||||
* 0x0017 "service info" frames — a TLV-ish metadata dump that carries
|
||||
* ASCII key/value pairs like `VendorID`, `SerialNumber`, `CFG`,
|
||||
* `ReportDescriptor`, etc.
|
||||
*
|
||||
* We don't decode the TLV structure yet (the framing is not fully
|
||||
* documented). Instead we extract all printable ASCII runs of length
|
||||
* ≥ 3 so the log tells you which keys/values the frame contains.
|
||||
*/
|
||||
data class ServiceInfo(val asciiTokens: List<String>, val payloadSize: Int) : HidFrameType()
|
||||
|
||||
data object Other : HidFrameType()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Minimum length for an ASCII run to count as a token in a ServiceInfo frame. */
|
||||
private const val MIN_ASCII_TOKEN_LEN = 3
|
||||
|
||||
internal fun classify(payload: ByteArray): HidFrameType {
|
||||
// Service directory frame — starts with FE 00 00 and contains repeated
|
||||
// [len=4? ascii service name + 4B flags] blocks. For logging, extract names.
|
||||
@@ -111,7 +132,40 @@ internal class HidTracker(private val log: (String) -> Unit) {
|
||||
return HidFrameType.Terminator(payload.size)
|
||||
}
|
||||
|
||||
// "Service info" frame — 4-byte magic 00 00 10 00 followed by a TLV-ish
|
||||
// payload with ASCII keys and mixed binary values. Observed on AirPods Pro 2
|
||||
// USB-C after the descriptor batch.
|
||||
if (payload.size >= 8 &&
|
||||
payload[0] == 0x00.toByte() &&
|
||||
payload[1] == 0x00.toByte() &&
|
||||
payload[2] == 0x10.toByte() &&
|
||||
payload[3] == 0x00.toByte()
|
||||
) {
|
||||
return HidFrameType.ServiceInfo(
|
||||
asciiTokens = extractAsciiTokens(payload),
|
||||
payloadSize = payload.size,
|
||||
)
|
||||
}
|
||||
|
||||
return HidFrameType.Other
|
||||
}
|
||||
|
||||
/** Extract printable ASCII runs ≥ 3 chars. Skips all non-printable bytes. */
|
||||
private fun extractAsciiTokens(payload: ByteArray): List<String> {
|
||||
val tokens = mutableListOf<String>()
|
||||
var i = 0
|
||||
while (i < payload.size) {
|
||||
val startByte = payload[i].toInt() and 0xFF
|
||||
if (startByte in 0x20..0x7E) {
|
||||
val start = i
|
||||
while (i < payload.size && (payload[i].toInt() and 0xFF) in 0x20..0x7E) i++
|
||||
val run = String(payload, start, i - start, Charsets.US_ASCII)
|
||||
if (run.length >= MIN_ASCII_TOKEN_LEN) tokens += run
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Information about the case itself (message type 0x23, requested via 0x22).
|
||||
*
|
||||
* Fields defined by the Wireshark AAP dissector:
|
||||
* - CaseInfoMessageVersion
|
||||
* - CaseInfoVID / CaseInfoPID / CaseInfoVIDSource
|
||||
* - CaseInfoColor
|
||||
* - CaseInfoVersion (case firmware)
|
||||
* - CaseInfoName
|
||||
*
|
||||
* The Wireshark dissector does not yet decode the per-field byte offsets
|
||||
* reliably — the payload length and presence of each field varies across
|
||||
* models. This class holds the raw bytes for future iteration once more
|
||||
* captures are available, and a best-effort named-field view.
|
||||
*/
|
||||
data class AapCaseInfo(
|
||||
/** The complete payload (after the 6-byte header) for future analysis. */
|
||||
val rawPayload: ByteArray,
|
||||
val messageVersion: Int? = null,
|
||||
val vid: Int? = null,
|
||||
val pid: Int? = null,
|
||||
val vidSource: Int? = null,
|
||||
val color: Int? = null,
|
||||
val version: String? = null,
|
||||
val name: String? = null,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AapCaseInfo) return false
|
||||
if (!rawPayload.contentEquals(other.rawPayload)) return false
|
||||
if (messageVersion != other.messageVersion) return false
|
||||
if (vid != other.vid) return false
|
||||
if (pid != other.pid) return false
|
||||
if (vidSource != other.vidSource) return false
|
||||
if (color != other.color) return false
|
||||
if (version != other.version) return false
|
||||
if (name != other.name) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var r = rawPayload.contentHashCode()
|
||||
r = 31 * r + (messageVersion ?: 0)
|
||||
r = 31 * r + (vid ?: 0)
|
||||
r = 31 * r + (pid ?: 0)
|
||||
r = 31 * r + (vidSource ?: 0)
|
||||
r = 31 * r + (color ?: 0)
|
||||
r = 31 * r + (version?.hashCode() ?: 0)
|
||||
r = 31 * r + (name?.hashCode() ?: 0)
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -34,5 +34,6 @@ sealed class AapCommand {
|
||||
data class SetAllowOffOption(val enabled: Boolean) : AapCommand()
|
||||
data class SetStemConfig(val claimedPressMask: Int) : AapCommand()
|
||||
data class SetSleepDetection(val enabled: Boolean) : AapCommand()
|
||||
data class SetDynamicEndOfCharge(val enabled: Boolean) : AapCommand()
|
||||
data class SetDeviceName(val name: String) : AapCommand()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Catalog of AAP control/setting IDs — the 8-bit ID at payload byte 0 of a
|
||||
* Control message (see [AapMessageType.CONTROL], opcode 0x0009).
|
||||
*
|
||||
* Sources:
|
||||
* - https://github.com/pabloaul/apple-wireshark/blob/main/plugins/aacp.lua
|
||||
* - LibrePods / MagicPodsCore
|
||||
* - Real device captures (AirPods Pro 1, Pro 2 USB-C, Pro 3)
|
||||
*
|
||||
* Where CAPod ships a setting under a different user-facing name than the
|
||||
* Wireshark catalog uses, both names are preserved — the enum uses the
|
||||
* Wireshark name, UI-facing strings use the CAPod name.
|
||||
*/
|
||||
enum class AapControlId(val value: Int, val wiresharkName: String) {
|
||||
MIC_MODE(0x01, "Mic Mode"),
|
||||
SCAN(0x02, "Scan"),
|
||||
RESET(0x03, "Reset"),
|
||||
BASIC_DOUBLE_TAP_MODE(0x04, "Basic Double Tap Mode"),
|
||||
BUTTON_SEND_MODE(0x05, "Button Send Mode"),
|
||||
OWNERSHIP_STATE(0x06, "Ownership state"),
|
||||
TAP_INTERVAL(0x07, "Tap Interval"),
|
||||
|
||||
/** Request the connected bud to go secondary. */
|
||||
BUD_ROLE(0x08, "Bud Role"),
|
||||
|
||||
DEBUG_GET_DATA(0x09, "Debug Get Data"),
|
||||
IN_EAR_DETECTION(0x0A, "In Ear Detection"),
|
||||
|
||||
/** Aka "Dynamic Latency". */
|
||||
JITTER_BUFFER(0x0B, "Jitter Buffer"),
|
||||
|
||||
DOUBLE_TAP_MODE(0x0C, "Double Tap Mode"),
|
||||
LISTEN_MODE(0x0D, "Listen Mode"),
|
||||
HEART_RATE_MONITOR_1(0x0E, "Heart Rate Monitor"),
|
||||
HEART_RATE_MONITOR_2(0x0F, "Heart Rate Monitor"),
|
||||
UNKNOWN_0X10(0x10, "Unknown/Unassigned"),
|
||||
SWITCH_CONTROL(0x11, "Switch Control"),
|
||||
VOICE_TRIGGER(0x12, "Voice Trigger"),
|
||||
|
||||
/** "Dictation over AirPods" for Siri. */
|
||||
DOAP_MODE(0x13, "DoAP mode"),
|
||||
|
||||
SINGLE_CLICK(0x14, "Single Click"),
|
||||
DOUBLE_CLICK(0x15, "Double Click"),
|
||||
CLICK_AND_HOLD(0x16, "Click and Hold"),
|
||||
|
||||
/** CAPod ships this as "Press Speed". */
|
||||
DOUBLE_CLICK_INTERVAL(0x17, "Double Click Interval"),
|
||||
|
||||
/** CAPod ships this as "Press Hold Duration". */
|
||||
CLICK_AND_HOLD_INTERVAL(0x18, "Click and Hold Interval"),
|
||||
|
||||
UNKNOWN_0X19(0x19, "Unknown/Unassigned"),
|
||||
LISTENING_MODE_CONFIGS(0x1A, "Listening Mode Configs"),
|
||||
ONE_BUD_ANC_MODE(0x1B, "One Bud ANC Mode"),
|
||||
CROWN_ROTATION_DIRECTION(0x1C, "Crown Rotation Direction"),
|
||||
UNKNOWN_0X1D(0x1D, "Unknown/Unassigned"),
|
||||
AUTO_ANSWER_MODE(0x1E, "Auto Answer Mode"),
|
||||
|
||||
/** CAPod ships this as "Tone Volume". */
|
||||
CHIME_VOLUME(0x1F, "Chime Volume"),
|
||||
|
||||
SMART_ROUTING_MODE(0x20, "Smart Routing Mode"),
|
||||
UNKNOWN_0X21(0x21, "Unknown/Unassigned"),
|
||||
HFP_UPLINK_MODE(0x22, "HFP Uplink Mode"),
|
||||
|
||||
/** CAPod ships this as "Volume Swipe Length". */
|
||||
VOLUME_SWIPE_INTERVAL(0x23, "Volume Swipe Interval"),
|
||||
|
||||
/** CAPod ships this as "End Call / Mute Mic". */
|
||||
CALL_MANAGEMENT_CONFIG(0x24, "Call Management Config"),
|
||||
|
||||
/** CAPod ships this as "Volume Swipe". */
|
||||
VOLUME_SWIPE_MODE(0x25, "Volume Swipe Mode"),
|
||||
|
||||
/**
|
||||
* "Adaptive Volume" per Wireshark. CAPod ships it as "Personalized Volume"
|
||||
* because that matches the iOS Settings.app label — unclear if this is
|
||||
* the same feature or the two sources disagree on naming.
|
||||
*/
|
||||
ADAPTIVE_VOLUME(0x26, "Adaptive Volume"),
|
||||
|
||||
SOFTWARE_MUTE(0x27, "Software Mute"),
|
||||
|
||||
/** CAPod ships this as "Conversational Awareness". */
|
||||
CONVERSATION_DETECT(0x28, "Conversation Detect"),
|
||||
|
||||
SELECTIVE_SPEECH_LISTENING(0x29, "Selective Speech Listening"),
|
||||
UNKNOWN_0X2A(0x2A, "Unknown/Unassigned"),
|
||||
UNKNOWN_0X2B(0x2B, "Unknown/Unassigned"),
|
||||
HEARING_AID(0x2C, "Hearing Aid"),
|
||||
UNKNOWN_0X2D(0x2D, "Unknown/Unassigned"),
|
||||
|
||||
/** CAPod ships this as "Adaptive Audio Noise". */
|
||||
AUTO_ANC_STRENGTH(0x2E, "Auto ANC Strength"),
|
||||
|
||||
HEARING_AID_GAIN_SWIPE(0x2F, "Hearing Aid Gain Swipe"),
|
||||
HEART_RATE_MONITOR_3(0x30, "Heart Rate Monitor"),
|
||||
IN_CASE_TONE(0x31, "In-Case Tone"),
|
||||
SIRI_MULTITONE(0x32, "Siri Multitone"),
|
||||
HEARING_ASSIST(0x33, "Hearing Assist"),
|
||||
ALLOW_OFF_OPTION(0x34, "Allow Off Option"),
|
||||
SLEEP_DETECTION(0x35, "Sleep Detection"),
|
||||
ALLOW_AUTO_CONNECT_FROM_AUDIO_ACCESSORY(0x36, "Allow Auto Connect from Audio Accessory"),
|
||||
HEARING_PROTECTION_PPE(0x37, "Hearing Protection PPE"),
|
||||
PPE_CAP_LEVEL_CONFIG(0x38, "PPE Cap Level Config"),
|
||||
|
||||
/** CAPod ships this as "Stem Config". */
|
||||
RAW_GESTURES_CONFIG(0x39, "Raw Gestures Config"),
|
||||
|
||||
ALLOW_TEMPORARY_MANAGED_PAIRING(0x3A, "Allow Temporary Managed Pairing"),
|
||||
DYNAMIC_END_OF_CHARGE(0x3B, "Dynamic End of Charge"),
|
||||
SYSTEM_SIRI_MODE(0x3C, "System Siri Mode"),
|
||||
|
||||
/** "hearingAidV2SourceRegionSupport" per Wireshark. */
|
||||
HEARING_AID_GENERIC(0x3D, "Hearing Aid Generic"),
|
||||
|
||||
UPLINK_EQ_BUD(0x3E, "Uplink EQ Bud"),
|
||||
UPLINK_EQ_SOURCE(0x3F, "Uplink EQ Source"),
|
||||
|
||||
/** Separate from [IN_CASE_TONE] (0x31) — this is a volume level, not an on/off. */
|
||||
IN_CASE_TONE_VOLUME(0x40, "In Case Tone Volume"),
|
||||
|
||||
DISABLE_BUTTON_INPUT(0x41, "Disable Button Input"),
|
||||
EXTENDED_HOLD_AND_RELEASE(0x42, "Extended Hold and Release"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val byValue: Map<Int, AapControlId> = entries.associateBy { it.value }
|
||||
fun byValue(value: Int): AapControlId? = byValue[value]
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,96 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Device identity parsed from the AAP handshake response (message type 0x1D).
|
||||
* Device identity parsed from the AAP Information message (type 0x1D).
|
||||
*
|
||||
* Field ordering and semantics are sourced from the Wireshark AAP dissector:
|
||||
* <https://github.com/pabloaul/apple-wireshark/blob/main/plugins/aacp.lua>
|
||||
*
|
||||
* Segments 0-10 are NUL-delimited UTF-8. Segments 11-12 are fixed 17-byte
|
||||
* UUIDs (may contain 0x00 — never treat as strings). Segments 13-14 are
|
||||
* NUL-delimited timestamps (Unix epoch seconds, ASCII).
|
||||
*
|
||||
* Any field past the system fields may be absent on older devices or
|
||||
* truncated payloads — all optional fields are nullable.
|
||||
*/
|
||||
data class AapDeviceInfo(
|
||||
/** Segment 0 — user-visible device name ("AirPods Pro"). */
|
||||
val name: String,
|
||||
/** Segment 1 — Apple model identifier ("A2084"). */
|
||||
val modelNumber: String,
|
||||
/** Segment 2 — manufacturer string ("Apple Inc."). */
|
||||
val manufacturer: String,
|
||||
/** Segment 3 — system (case) serial number. */
|
||||
val serialNumber: String,
|
||||
/** Segment 4 — currently-running firmware version. */
|
||||
val firmwareVersion: String,
|
||||
/** Segment 5 — pending firmware version after next reboot. Null if equal to active. */
|
||||
val firmwareVersionPending: String? = null,
|
||||
/** Segment 6 — hardware revision ("1.0.0"). */
|
||||
val hardwareVersion: String? = null,
|
||||
/** Segment 7 — External Accessory protocol name ("com.apple.accessory.updater.app.71"). */
|
||||
val eaProtocolName: String? = null,
|
||||
/** Segment 8 — left earbud serial. */
|
||||
val leftEarbudSerial: String? = null,
|
||||
/** Segment 9 — right earbud serial. */
|
||||
val rightEarbudSerial: String? = null,
|
||||
val buildNumber: String? = null,
|
||||
)
|
||||
/**
|
||||
* Segment 10 — marketing/build version (e.g. "8454624"). Originally
|
||||
* mis-labeled `buildNumber` in CAPod; the Wireshark dissector calls it
|
||||
* "Marketing Version".
|
||||
*/
|
||||
val marketingVersion: String? = null,
|
||||
/** Segment 11 — opaque 17-byte left-bud UUID. May contain arbitrary bytes. */
|
||||
val leftEarbudUuid: ByteArray? = null,
|
||||
/** Segment 12 — opaque 17-byte right-bud UUID. May contain arbitrary bytes. */
|
||||
val rightEarbudUuid: ByteArray? = null,
|
||||
/** Segment 13 — first-time-pairing timestamp for the left bud. */
|
||||
val leftEarbudFirstPaired: Instant? = null,
|
||||
/** Segment 14 — first-time-pairing timestamp for the right bud. */
|
||||
val rightEarbudFirstPaired: Instant? = null,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AapDeviceInfo) return false
|
||||
if (name != other.name) return false
|
||||
if (modelNumber != other.modelNumber) return false
|
||||
if (manufacturer != other.manufacturer) return false
|
||||
if (serialNumber != other.serialNumber) return false
|
||||
if (firmwareVersion != other.firmwareVersion) return false
|
||||
if (firmwareVersionPending != other.firmwareVersionPending) return false
|
||||
if (hardwareVersion != other.hardwareVersion) return false
|
||||
if (eaProtocolName != other.eaProtocolName) return false
|
||||
if (leftEarbudSerial != other.leftEarbudSerial) return false
|
||||
if (rightEarbudSerial != other.rightEarbudSerial) return false
|
||||
if (marketingVersion != other.marketingVersion) return false
|
||||
if (!leftEarbudUuid.contentOptionalEquals(other.leftEarbudUuid)) return false
|
||||
if (!rightEarbudUuid.contentOptionalEquals(other.rightEarbudUuid)) return false
|
||||
if (leftEarbudFirstPaired != other.leftEarbudFirstPaired) return false
|
||||
if (rightEarbudFirstPaired != other.rightEarbudFirstPaired) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var r = name.hashCode()
|
||||
r = 31 * r + modelNumber.hashCode()
|
||||
r = 31 * r + manufacturer.hashCode()
|
||||
r = 31 * r + serialNumber.hashCode()
|
||||
r = 31 * r + firmwareVersion.hashCode()
|
||||
r = 31 * r + (firmwareVersionPending?.hashCode() ?: 0)
|
||||
r = 31 * r + (hardwareVersion?.hashCode() ?: 0)
|
||||
r = 31 * r + (eaProtocolName?.hashCode() ?: 0)
|
||||
r = 31 * r + (leftEarbudSerial?.hashCode() ?: 0)
|
||||
r = 31 * r + (rightEarbudSerial?.hashCode() ?: 0)
|
||||
r = 31 * r + (marketingVersion?.hashCode() ?: 0)
|
||||
r = 31 * r + (leftEarbudUuid?.contentHashCode() ?: 0)
|
||||
r = 31 * r + (rightEarbudUuid?.contentHashCode() ?: 0)
|
||||
r = 31 * r + (leftEarbudFirstPaired?.hashCode() ?: 0)
|
||||
r = 31 * r + (rightEarbudFirstPaired?.hashCode() ?: 0)
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray?.contentOptionalEquals(other: ByteArray?): Boolean =
|
||||
if (this == null || other == null) this === other else contentEquals(other)
|
||||
|
||||
@@ -74,6 +74,33 @@ interface AapDeviceProfile {
|
||||
*/
|
||||
fun decodeStemPress(message: AapMessage): StemPressEvent?
|
||||
|
||||
/**
|
||||
* Encode a Case Info request (command 0x22). Returns null when this profile
|
||||
* shouldn't probe the case — older models may not respond, or the payload
|
||||
* schema is unconfirmed for that model. Fire-and-forget: if the device
|
||||
* doesn't reply, nothing happens.
|
||||
*/
|
||||
fun encodeCaseInfoRequest(): ByteArray? = null
|
||||
|
||||
/**
|
||||
* Decode a Case Info response (command 0x23). Returns null if the message
|
||||
* is not a Case Info response or the payload cannot be recognised.
|
||||
*/
|
||||
fun decodeCaseInfo(message: AapMessage): AapCaseInfo? = null
|
||||
|
||||
/**
|
||||
* Decode a Sleep Detection Update event (command 0x57). Returns null for
|
||||
* any other message type. Payload schema is not fully documented — the
|
||||
* default implementation returns the raw bytes verbatim.
|
||||
*/
|
||||
fun decodeSleepEvent(message: AapMessage): AapSleepEvent? = null
|
||||
|
||||
/**
|
||||
* Decode a Dynamic End-of-Charge event (command 0x59). Returns null for
|
||||
* any other message type.
|
||||
*/
|
||||
fun decodeDynamicEndOfChargeEvent(message: AapMessage): AapDynamicEndOfChargeEvent? = null
|
||||
|
||||
companion object {
|
||||
fun forModel(model: PodModel): AapDeviceProfile = DefaultAapDeviceProfile(model)
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Push-only event paired with the Dynamic End-of-Charge setting (control ID 0x3B).
|
||||
* Message type 0x59 — reports charge-cap status transitions (e.g. "80% cap reached").
|
||||
*
|
||||
* Payload schema is not yet documented publicly — this type preserves the raw
|
||||
* bytes so future capture-based analysis can fill in structured fields.
|
||||
*/
|
||||
data class AapDynamicEndOfChargeEvent(
|
||||
val rawPayload: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AapDynamicEndOfChargeEvent) return false
|
||||
return rawPayload.contentEquals(other.rawPayload)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = rawPayload.contentHashCode()
|
||||
}
|
||||
+6
-31
@@ -1,36 +1,5 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* A parsed AAP protocol message.
|
||||
*/
|
||||
data class AapMessage(
|
||||
val raw: ByteArray,
|
||||
val commandType: Int,
|
||||
val payload: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AapMessage) return false
|
||||
return raw.contentEquals(other.raw)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = raw.contentHashCode()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parse a complete AAP message from raw bytes.
|
||||
* AAP messages have the format: [4-byte header] [2-byte command type] [payload…]
|
||||
* Minimum message size is 6 bytes (header + command type with no payload).
|
||||
*/
|
||||
fun parse(raw: ByteArray): AapMessage? {
|
||||
if (raw.size < 6) return null
|
||||
val commandType = (raw[4].toInt() and 0xFF) or ((raw[5].toInt() and 0xFF) shl 8)
|
||||
val payload = if (raw.size > 6) raw.copyOfRange(6, raw.size) else ByteArray(0)
|
||||
return AapMessage(raw = raw.copyOf(), commandType = commandType, payload = payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulates bytes from a stream and emits complete [AapMessage] objects.
|
||||
*
|
||||
@@ -39,6 +8,12 @@ data class AapMessage(
|
||||
*
|
||||
* AAP message framing: first 4 bytes are header, bytes 2-3 (little-endian)
|
||||
* indicate total message length (excluding the first 4 header bytes).
|
||||
*
|
||||
* Note: production code bypasses this framer — [AapConnection.readLoop]
|
||||
* parses whole reads directly because L2CAP SEQPACKET already delivers
|
||||
* per-frame boundaries. The framer is retained for future stream-mode
|
||||
* paths but its splitting rule doesn't match every real capture
|
||||
* (see `AapFramerTest` for the shape it expects).
|
||||
*/
|
||||
class AapFramer {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Catalog of AAP (Apple Accessory Protocol, also "AACP") message types —
|
||||
* the 16-bit opcode at bytes 4-5 of a Message-type packet (packet type 0x04).
|
||||
*
|
||||
* Sources:
|
||||
* - https://github.com/pabloaul/apple-wireshark/blob/main/plugins/aacp.lua
|
||||
* - LibrePods / MagicPodsCore
|
||||
* - Real device captures (AirPods Pro 1, Pro 2 USB-C, Pro 3)
|
||||
*
|
||||
* Entries whose names start with `UNKNOWN_` exist in Wireshark's dissector
|
||||
* but have no known meaning. They're catalogued here so unhandled-message
|
||||
* logging still reports a named opcode instead of a bare int.
|
||||
*/
|
||||
enum class AapMessageType(val value: Int, val wiresharkName: String) {
|
||||
CAPABILITIES_REQUEST(0x0001, "Capabilities Request"),
|
||||
CAPABILITIES(0x0002, "Capabilities"),
|
||||
BATTERY_INFO_REQUEST(0x0003, "Battery Info Request"),
|
||||
BATTERY_INFO(0x0004, "Battery Info"),
|
||||
EAR_DETECTION_REQUEST(0x0005, "Ear Detection Request"),
|
||||
EAR_DETECTION(0x0006, "Ear Detection"),
|
||||
BUD_ROLE_REQUEST(0x0007, "Bud Role Request"),
|
||||
BUD_ROLE(0x0008, "Bud Role"),
|
||||
|
||||
/** Controls / settings. The control ID is payload byte 0 — see [AapControlId]. */
|
||||
CONTROL(0x0009, "Control"),
|
||||
|
||||
DEVICE_LIST(0x000B, "Device List"),
|
||||
MAC_ADDRESS(0x000C, "MAC Address"),
|
||||
STREAM_STATE_INFO_REQUEST(0x000D, "Stream State Info Request"),
|
||||
AUDIO_SOURCE(0x000E, "Audio Source"),
|
||||
SET_NOTIFICATION_FILTER(0x000F, "Set Notification Filter"),
|
||||
SMART_ROUTING_1(0x0010, "Smart Routing"),
|
||||
SMART_ROUTING_2(0x0011, "Smart Routing"),
|
||||
EASY_PAIR_REQUEST(0x0012, "Easy Pair Request?"),
|
||||
CONNECT_PRIORITY_LIST(0x0014, "Connect Priority List"),
|
||||
TRIANGLE_STATUS_REQUEST(0x0015, "Triangle Status Request"),
|
||||
MAGNET_LINK(0x0016, "Magnet Link"),
|
||||
|
||||
/**
|
||||
* Buddy Command per Wireshark. In CAPod we treat payloads of this opcode as
|
||||
* HID descriptor frames (Service Directory / descriptor / terminator — see [HidTracker]).
|
||||
* Both interpretations may be correct for different sub-payloads.
|
||||
*/
|
||||
BUDDY_COMMAND(0x0017, "BuddyCommand"),
|
||||
|
||||
STEM_PRESS(0x0019, "Stem Press"),
|
||||
RENAME(0x001A, "Rename"),
|
||||
TIMESTAMP(0x001B, "Timestamp"),
|
||||
INFORMATION(0x001D, "Information"),
|
||||
SEND_EXTERNAL_ACCESSORY_SESSION_PACKET(0x001E, "Send External Accessory Session Packet"),
|
||||
NOTIFY_SESSION_STATE(0x001F, "Notify Session State?"),
|
||||
SEND_REMOTE_FIRMWARE_AUTH_DATA(0x0020, "Send Remote Firmware Auth Data"),
|
||||
UNKNOWN_0X21(0x0021, "Unknown"),
|
||||
CASE_INFO_REQUEST(0x0022, "Case Info Request"),
|
||||
CASE_INFO(0x0023, "Case Info"),
|
||||
SEND_DEVICE_INFO(0x0024, "Send Device Info?"),
|
||||
CERTIFICATES_REQUEST(0x0026, "Certificates Request"),
|
||||
CERTIFICATES(0x0027, "Certificates"),
|
||||
GYRO_INFO(0x0028, "Gyro Info"),
|
||||
SET_COUNTRY_CODE(0x0029, "Set Country Code"),
|
||||
STREAM_STATE_INFO(0x002B, "Stream State Info"),
|
||||
GAPA_CHALLENGE(0x002C, "GAPA Challenge"),
|
||||
CONNECTED_DEVICES_REQUEST(0x002D, "Connected Devices Request"),
|
||||
CONNECTED_DEVICES(0x002E, "Connected Devices"),
|
||||
MAGIC_KEYS_REQUEST(0x0030, "Magic Keys Request"),
|
||||
MAGIC_KEYS(0x0031, "Magic Keys"),
|
||||
MAGIC_KEYS_2(0x0032, "Magic Keys"),
|
||||
UNKNOWN_0X40(0x0040, "Unknown"),
|
||||
SEND_SMART_ROUTING_2_INFO(0x0044, "Send Smart Routing 2.0 Info"),
|
||||
FAST_CONNECT_COMPLETE(0x0045, "Fast Connect Complete?"),
|
||||
BUD_SWAP_2_PROCEDURE(0x0047, "Bud Swap 2.0 Procedure?"),
|
||||
SWAP_IMMINENT_CONFIRM(0x0048, "Swap Imminent Confirm?"),
|
||||
BUD_SWAP_2_COMPLETION(0x0049, "Bud Swap 2.0 Completion?"),
|
||||
SWAP_COMPLETE_CONFIRM(0x004A, "Swap Complete Confirm?"),
|
||||
CONVERSATIONAL_AWARENESS(0x004B, "Conversational Awareness"),
|
||||
ADAPTIVE_VOLUME_MESSAGE(0x004C, "Adaptive Volume Message"),
|
||||
|
||||
/**
|
||||
* Source Feature Capabilities. Sent by the source after Connect Response to
|
||||
* advertise what it supports. In CAPod this is "InitExt" — we only emit a
|
||||
* fixed template, we don't read/decode the reply.
|
||||
*/
|
||||
SOURCE_FEATURE_CAPABILITIES(0x004D, "Source Feature Capabilities"),
|
||||
|
||||
FEATURE_PROX_CARD_STATUS_UPDATE(0x004E, "Feature ProxCard Status Update"),
|
||||
UARP_DATA(0x004F, "UARP Data"),
|
||||
UNKNOWN_0X50(0x0050, "Unknown"),
|
||||
SOURCE_CONTEXT(0x0052, "Source Context"),
|
||||
|
||||
/**
|
||||
* PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment) —
|
||||
* hearing-aid configuration for the iOS 18.1+ hearing-aid feature on AirPods
|
||||
* Pro 2. Decoded as 4 × 8 Float32 values (see [AapSetting.PmeConfig]); see
|
||||
* that data class for the layout rationale. "PME Config" is the label the
|
||||
* Wireshark AAP dissector uses for this opcode.
|
||||
*/
|
||||
PME_CONFIG(0x0053, "PME Config"),
|
||||
|
||||
/**
|
||||
* Configures the AirPods radio's allowed RF bands. AirPods Pro 2 USB-C
|
||||
* and newer can transmit on 5 GHz / 6 GHz U-NII bands in addition to
|
||||
* 2.4 GHz ISM — Apple uses this for the proprietary lossless audio mode
|
||||
* with Apple Vision Pro. The "type" field is a band code from Apple's
|
||||
* `BSM_BAND_CODE_*` enum (0x0 = ISM24, 0x1/0x2/0x3 = U-NII-1/3/4,
|
||||
* 0x4–0x7 = U-NII-5A/B/C/D, 0x8 = INVALID; identified via FCC firmware
|
||||
* analysis). "Set Band Edges" is the label the Wireshark AAP dissector
|
||||
* uses for this opcode. CAPod observes but does not decode the payload.
|
||||
*/
|
||||
SET_BAND_EDGES(0x0054, "Set Band Edges"),
|
||||
UNKNOWN_0X55(0x0055, "Unknown"),
|
||||
USB_SPATIAL_SENSOR_DATA_REQUEST(0x0056, "USB Spatial Sensor Data Request"),
|
||||
SLEEP_DETECTION_UPDATE(0x0057, "Sleep Detection Update"),
|
||||
UNKNOWN_0X58(0x0058, "Unknown"),
|
||||
DYNAMIC_END_OF_CHARGE(0x0059, "Dynamic End Of Charge"),
|
||||
PERSONAL_TRANSLATION(0x0060, "Personal Translation"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val byValue: Map<Int, AapMessageType> = entries.associateBy { it.value }
|
||||
fun byValue(value: Int): AapMessageType? = byValue[value]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* A parsed AAP (AACP) frame. Packet type lives at bytes 0-1 (little-endian)
|
||||
* and selects which variant we're looking at.
|
||||
*
|
||||
* Per the Wireshark dissector, packet types are:
|
||||
* - 0x0000 — Connect (session open request; source → pods)
|
||||
* - 0x0001 — Connect Response (pods → source)
|
||||
* - 0x0002 — Disconnect
|
||||
* - 0x0003 — Disconnect Response
|
||||
* - 0x0004 — Message (the large Message type with a 16-bit message/command ID)
|
||||
*
|
||||
* Only `Message` packets carry the `(commandType, payload)` pair that the
|
||||
* existing decoder pipeline consumes; the other variants have their own
|
||||
* field schema and bypass the decoder.
|
||||
*/
|
||||
sealed class AapPacket(val raw: ByteArray) {
|
||||
|
||||
/** Source → pods session open. We emit this as our handshake. */
|
||||
class Connect(
|
||||
raw: ByteArray,
|
||||
val service: Int,
|
||||
val major: Int,
|
||||
val minor: Int,
|
||||
val features: ULong,
|
||||
) : AapPacket(raw)
|
||||
|
||||
/**
|
||||
* Pods → source response to a Connect. The `features` bitmask is opaque
|
||||
* for now (no public bit-to-feature mapping); CAPod logs it and stores
|
||||
* it in `AapPodState` for future correlation work.
|
||||
*/
|
||||
class ConnectResponse(
|
||||
raw: ByteArray,
|
||||
val service: Int,
|
||||
val status: Int,
|
||||
val major: Int,
|
||||
val minor: Int,
|
||||
val features: ULong,
|
||||
) : AapPacket(raw)
|
||||
|
||||
class Disconnect(
|
||||
raw: ByteArray,
|
||||
val service: Int,
|
||||
val status: Int,
|
||||
) : AapPacket(raw)
|
||||
|
||||
class DisconnectResponse(
|
||||
raw: ByteArray,
|
||||
val service: Int,
|
||||
) : AapPacket(raw)
|
||||
|
||||
/**
|
||||
* Message packet — bytes 4-5 are the message/command ID. This is what
|
||||
* [AapDeviceProfile.decodeSetting] / `decodeBattery` / etc. consume.
|
||||
*
|
||||
* Typealiased to `AapMessage` for backward compatibility.
|
||||
*/
|
||||
class Message(
|
||||
raw: ByteArray,
|
||||
val commandType: Int,
|
||||
val payload: ByteArray,
|
||||
) : AapPacket(raw) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Message) return false
|
||||
return raw.contentEquals(other.raw)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = raw.contentHashCode()
|
||||
|
||||
override fun toString(): String =
|
||||
"AapPacket.Message(cmd=0x${"%04X".format(commandType)}, payload=${payload.size}B)"
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parse a Message-type AAP frame. Returns null if the bytes aren't
|
||||
* a complete Message packet (packet type != 0x0004, or payload too short).
|
||||
* Non-Message packets (Connect Response etc.) return null here —
|
||||
* use [AapPacket.parse] if you need to handle them.
|
||||
*/
|
||||
fun parse(raw: ByteArray): Message? {
|
||||
val packet = AapPacket.parse(raw) ?: return null
|
||||
return packet as? Message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Unknown / unrecognised packet type. Preserved for logging. */
|
||||
class Unknown(raw: ByteArray, val packetType: Int) : AapPacket(raw)
|
||||
|
||||
companion object {
|
||||
private const val PACKET_TYPE_CONNECT = 0x0000
|
||||
private const val PACKET_TYPE_CONNECT_RESPONSE = 0x0001
|
||||
private const val PACKET_TYPE_DISCONNECT = 0x0002
|
||||
private const val PACKET_TYPE_DISCONNECT_RESPONSE = 0x0003
|
||||
private const val PACKET_TYPE_MESSAGE = 0x0004
|
||||
|
||||
/**
|
||||
* Parse a raw AAP frame. Returns:
|
||||
* - `null` if the buffer is too short to even read the packet type.
|
||||
* - An [Unknown] if the packet type isn't one we recognise.
|
||||
* - The appropriate subclass otherwise.
|
||||
*
|
||||
* Note: this assumes one frame per call — L2CAP SEQPACKET delivers
|
||||
* complete frames per read, matching this expectation. The
|
||||
* (unused-in-prod) [AapFramer] uses a different splitting approach.
|
||||
*/
|
||||
fun parse(raw: ByteArray): AapPacket? {
|
||||
if (raw.size < 4) return null
|
||||
val packetType = readLe16(raw, 0)
|
||||
val service = readLe16(raw, 2)
|
||||
return when (packetType) {
|
||||
PACKET_TYPE_CONNECT -> parseConnect(raw, service)
|
||||
PACKET_TYPE_CONNECT_RESPONSE -> parseConnectResponse(raw, service)
|
||||
PACKET_TYPE_DISCONNECT -> parseDisconnect(raw, service)
|
||||
PACKET_TYPE_DISCONNECT_RESPONSE -> DisconnectResponse(raw.copyOf(), service)
|
||||
PACKET_TYPE_MESSAGE -> parseMessage(raw)
|
||||
else -> Unknown(raw.copyOf(), packetType)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseConnect(raw: ByteArray, service: Int): Connect? {
|
||||
if (raw.size < 16) return null
|
||||
val major = readLe16(raw, 4)
|
||||
val minor = readLe16(raw, 6)
|
||||
val features = readLe64(raw, 8)
|
||||
return Connect(raw.copyOf(), service, major, minor, features)
|
||||
}
|
||||
|
||||
private fun parseConnectResponse(raw: ByteArray, service: Int): ConnectResponse? {
|
||||
if (raw.size < 18) return null
|
||||
val status = readLe16(raw, 4)
|
||||
val major = readLe16(raw, 6)
|
||||
val minor = readLe16(raw, 8)
|
||||
val features = readLe64(raw, 10)
|
||||
return ConnectResponse(raw.copyOf(), service, status, major, minor, features)
|
||||
}
|
||||
|
||||
private fun parseDisconnect(raw: ByteArray, service: Int): Disconnect? {
|
||||
if (raw.size < 6) return null
|
||||
val status = readLe16(raw, 4)
|
||||
return Disconnect(raw.copyOf(), service, status)
|
||||
}
|
||||
|
||||
private fun parseMessage(raw: ByteArray): Message? {
|
||||
if (raw.size < 6) return null
|
||||
val commandType = readLe16(raw, 4)
|
||||
val payload = if (raw.size > 6) raw.copyOfRange(6, raw.size) else ByteArray(0)
|
||||
return Message(raw = raw.copyOf(), commandType = commandType, payload = payload)
|
||||
}
|
||||
|
||||
private fun readLe16(data: ByteArray, offset: Int): Int =
|
||||
(data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
private fun readLe64(data: ByteArray, offset: Int): ULong {
|
||||
var result = 0UL
|
||||
for (i in 0 until 8) {
|
||||
result = result or ((data[offset + i].toLong() and 0xFFL).toULong() shl (i * 8))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compat alias — CAPod historically named the Message packet just
|
||||
* `AapMessage`. Kept as a type alias so decoder signatures don't churn.
|
||||
*/
|
||||
typealias AapMessage = AapPacket.Message
|
||||
@@ -146,6 +146,15 @@ sealed class AapSetting {
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
/**
|
||||
* Apple's "Optimized Charge Limit" — AAP setting 0x3B, Apple-bool encoding. Distinct
|
||||
* from the older "Optimized Battery Charging" which Apple doesn't expose as a
|
||||
* user-controllable AAP setting. Supported models push this value on connect.
|
||||
*/
|
||||
data class DynamicEndOfCharge(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class InCaseTone(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
@@ -163,9 +172,26 @@ sealed class AapSetting {
|
||||
enum class AudioSourceType { NONE, CALL, MEDIA }
|
||||
}
|
||||
|
||||
data class EqBands(
|
||||
/**
|
||||
* Payload of message type 0x0053 — "PME Config" in the Wireshark AAP dissector.
|
||||
* PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment):
|
||||
* the hearing-aid configuration for Apple's iOS 18.1+ hearing-aid feature on
|
||||
* AirPods Pro 2.
|
||||
*
|
||||
* Decoded as 4 × 8 Float32 values — consistent with per-ear × per-profile
|
||||
* audiogram band gains (e.g. L/R × two environment profiles, 8 frequency
|
||||
* bands). CAPod previously called this "EQ bands".
|
||||
*
|
||||
* Callers should treat all-zero [sets] as "no hearing-aid profile configured"
|
||||
* — stock firmware reports zeros until the user runs Apple's Hearing Test /
|
||||
* hearing-aid setup.
|
||||
*/
|
||||
data class PmeConfig(
|
||||
val sets: List<List<Float>>,
|
||||
) : AapSetting()
|
||||
) : AapSetting() {
|
||||
val isAllZero: Boolean
|
||||
get() = sets.all { set -> set.all { it == 0f } }
|
||||
}
|
||||
|
||||
/** Per-pod placement reported by the device (command 0x06). */
|
||||
data class EarDetection(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Push-only event emitted by newer AirPods firmware when the Sleep Detection
|
||||
* feature (setting 0x35) is enabled (message type 0x57 "Sleep Detection Update").
|
||||
*
|
||||
* Payload schema is not yet documented publicly — this type preserves the raw
|
||||
* bytes so future capture-based analysis can fill in structured fields.
|
||||
*/
|
||||
data class AapSleepEvent(
|
||||
val rawPayload: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AapSleepEvent) return false
|
||||
return rawPayload.contentEquals(other.rawPayload)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = rawPayload.contentHashCode()
|
||||
}
|
||||
+236
-134
@@ -16,58 +16,42 @@ class DefaultAapDeviceProfile(
|
||||
) : AapDeviceProfile {
|
||||
|
||||
companion object {
|
||||
// AAP command types (bytes 4-5 of the message, little-endian)
|
||||
const val CMD_SETTINGS = 0x0009
|
||||
const val CMD_BATTERY = 0x0004
|
||||
const val CMD_DEVICE_INFO = 0x001D
|
||||
const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031
|
||||
const val CMD_EAR_DETECTION = 0x0006
|
||||
const val CMD_PRIMARY_POD = 0x0008
|
||||
const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B
|
||||
|
||||
// Setting IDs (first byte of settings command payload)
|
||||
const val SETTING_ANC_MODE = 0x0D
|
||||
const val SETTING_PRESS_SPEED = 0x17
|
||||
const val SETTING_PRESS_HOLD_DURATION = 0x18
|
||||
const val SETTING_NC_ONE_AIRPOD = 0x1B
|
||||
const val SETTING_TONE_VOLUME = 0x1F
|
||||
const val SETTING_VOLUME_SWIPE_LENGTH = 0x23
|
||||
const val SETTING_END_CALL_MUTE_MIC = 0x24
|
||||
const val SETTING_VOLUME_SWIPE = 0x25
|
||||
const val SETTING_PERSONALIZED_VOLUME = 0x26
|
||||
const val SETTING_CONVERSATIONAL_AWARENESS = 0x28
|
||||
const val SETTING_ADAPTIVE_AUDIO_NOISE = 0x2E
|
||||
const val SETTING_MICROPHONE_MODE = 0x01
|
||||
const val SETTING_EAR_DETECTION_ENABLED = 0x0A
|
||||
const val SETTING_LISTENING_MODE_CYCLE = 0x1A
|
||||
// Kept decoded internally (never exposed in UI): the device pushes 0x31 frames and dropping the
|
||||
// decode would fall through to "Unhandled message" logging and prevent lastMessageAt refresh,
|
||||
// which AAP freshness / boost logic in PodDevice.computeAapBoost depends on.
|
||||
// Originally labeled "Charging Sounds" but the real case tones are controlled over ATT, not here;
|
||||
// the actual effect of this setting is unknown, so we don't expose or write it.
|
||||
const val SETTING_IN_CASE_TONE = 0x31
|
||||
const val SETTING_ALLOW_OFF_OPTION = 0x34
|
||||
const val SETTING_SLEEP_DETECTION = 0x35
|
||||
const val SETTING_STEM_CONFIG = 0x39
|
||||
|
||||
// Known-but-unconfirmed setting IDs (H2+ exclusive). Decoded as UnknownSetting
|
||||
// to keep lastMessageAt fresh. Observed on Pro 2 USB-C and/or Pro 3.
|
||||
val UNCONFIRMED_SETTING_IDS = setOf(
|
||||
0x29, 0x2C, 0x2F, 0x30, 0x33, 0x37, 0x38, 0x3B, 0x3E,
|
||||
/**
|
||||
* Catalogued control IDs we see on-wire but don't yet model as a named
|
||||
* [AapSetting] subclass. Decoded as [AapSetting.UnknownSetting] so
|
||||
* `lastMessageAt` still refreshes (freshness feeds AAP quality boost).
|
||||
* Promote an entry to a dedicated subclass when its semantics are
|
||||
* confirmed from captures.
|
||||
*
|
||||
* Observed on Pro 2 USB-C and/or Pro 3.
|
||||
*/
|
||||
val UNMAPPED_SETTING_IDS = setOf(
|
||||
AapControlId.SELECTIVE_SPEECH_LISTENING.value, // 0x29
|
||||
AapControlId.HEARING_AID.value, // 0x2C
|
||||
AapControlId.HEARING_AID_GAIN_SWIPE.value, // 0x2F
|
||||
AapControlId.HEART_RATE_MONITOR_3.value, // 0x30
|
||||
AapControlId.HEARING_ASSIST.value, // 0x33
|
||||
AapControlId.HEARING_PROTECTION_PPE.value, // 0x37
|
||||
AapControlId.PPE_CAP_LEVEL_CONFIG.value, // 0x38
|
||||
AapControlId.UPLINK_EQ_BUD.value, // 0x3E
|
||||
)
|
||||
|
||||
// Command types for non-settings messages
|
||||
const val CMD_RENAME = 0x001E
|
||||
const val CMD_STEM_PRESS = 0x0019
|
||||
const val CMD_CONNECTED_DEVICES = 0x002E
|
||||
const val CMD_AUDIO_SOURCE = 0x000E
|
||||
const val CMD_EQ_DATA = 0x0053
|
||||
|
||||
// ANC mode wire values
|
||||
const val ANC_WIRE_OFF = 0x01
|
||||
const val ANC_WIRE_ON = 0x02
|
||||
const val ANC_WIRE_TRANSPARENCY = 0x03
|
||||
const val ANC_WIRE_ADAPTIVE = 0x04
|
||||
|
||||
/** Fixed length of each earbud UUID segment in the Information payload (segments 11 & 12). */
|
||||
private const val UUID_LEN = 17
|
||||
|
||||
/**
|
||||
* Models where sending a 0x22 CASE_INFO_REQUEST yields a 0x23 response.
|
||||
* Grow this list as captures confirm other models respond correctly.
|
||||
*/
|
||||
private val CASE_INFO_ALLOWLIST = setOf(
|
||||
PodModel.AIRPODS_PRO3,
|
||||
)
|
||||
}
|
||||
|
||||
private val supportedAncModes: List<AapSetting.AncMode.Value> by lazy {
|
||||
@@ -95,31 +79,32 @@ class DefaultAapDeviceProfile(
|
||||
)
|
||||
|
||||
override fun encodeCommand(command: AapCommand): ByteArray = when (command) {
|
||||
is AapCommand.SetAncMode -> buildSettingsMessage(SETTING_ANC_MODE, encodeAncMode(command.mode))
|
||||
is AapCommand.SetConversationalAwareness -> buildSettingsMessage(SETTING_CONVERSATIONAL_AWARENESS, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetPressSpeed -> buildSettingsMessage(SETTING_PRESS_SPEED, command.value.wireValue)
|
||||
is AapCommand.SetPressHoldDuration -> buildSettingsMessage(SETTING_PRESS_HOLD_DURATION, command.value.wireValue)
|
||||
is AapCommand.SetNcWithOneAirPod -> buildSettingsMessage(SETTING_NC_ONE_AIRPOD, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetToneVolume -> buildSettingsMessage(SETTING_TONE_VOLUME, command.level.coerceIn(0x0F, 0x64))
|
||||
is AapCommand.SetVolumeSwipeLength -> buildSettingsMessage(SETTING_VOLUME_SWIPE_LENGTH, command.value.wireValue)
|
||||
is AapCommand.SetVolumeSwipe -> buildSettingsMessage(SETTING_VOLUME_SWIPE, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(SETTING_PERSONALIZED_VOLUME, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetAncMode -> buildSettingsMessage(AapControlId.LISTEN_MODE.value, encodeAncMode(command.mode))
|
||||
is AapCommand.SetConversationalAwareness -> buildSettingsMessage(AapControlId.CONVERSATION_DETECT.value, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetPressSpeed -> buildSettingsMessage(AapControlId.DOUBLE_CLICK_INTERVAL.value, command.value.wireValue)
|
||||
is AapCommand.SetPressHoldDuration -> buildSettingsMessage(AapControlId.CLICK_AND_HOLD_INTERVAL.value, command.value.wireValue)
|
||||
is AapCommand.SetNcWithOneAirPod -> buildSettingsMessage(AapControlId.ONE_BUD_ANC_MODE.value, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetToneVolume -> buildSettingsMessage(AapControlId.CHIME_VOLUME.value, command.level.coerceIn(0x0F, 0x64))
|
||||
is AapCommand.SetVolumeSwipeLength -> buildSettingsMessage(AapControlId.VOLUME_SWIPE_INTERVAL.value, command.value.wireValue)
|
||||
is AapCommand.SetVolumeSwipe -> buildSettingsMessage(AapControlId.VOLUME_SWIPE_MODE.value, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(AapControlId.ADAPTIVE_VOLUME.value, encodeAppleBool(command.enabled))
|
||||
// Wire semantics are inverted: wire 0 = max noise reduction, wire 100 = min (transparency-like).
|
||||
// UI value 0..100 follows user intuition (100 = max NC), so flip on write/read.
|
||||
is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(SETTING_ADAPTIVE_AUDIO_NOISE, 100 - command.level.coerceIn(0, 100))
|
||||
is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(AapControlId.AUTO_ANC_STRENGTH.value, 100 - command.level.coerceIn(0, 100))
|
||||
is AapCommand.SetEndCallMuteMic -> buildEndCallMuteMicMessage(command.muteMic, command.endCall)
|
||||
is AapCommand.SetMicrophoneMode -> buildSettingsMessage(SETTING_MICROPHONE_MODE, command.mode.wireValue)
|
||||
is AapCommand.SetEarDetectionEnabled -> buildSettingsMessage(SETTING_EAR_DETECTION_ENABLED, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetListeningModeCycle -> buildSettingsMessage(SETTING_LISTENING_MODE_CYCLE, command.modeMask and 0x0F)
|
||||
is AapCommand.SetAllowOffOption -> buildSettingsMessage(SETTING_ALLOW_OFF_OPTION, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetStemConfig -> buildSettingsMessage(SETTING_STEM_CONFIG, command.claimedPressMask and 0x0F)
|
||||
is AapCommand.SetSleepDetection -> buildSettingsMessage(SETTING_SLEEP_DETECTION, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetMicrophoneMode -> buildSettingsMessage(AapControlId.MIC_MODE.value, command.mode.wireValue)
|
||||
is AapCommand.SetEarDetectionEnabled -> buildSettingsMessage(AapControlId.IN_EAR_DETECTION.value, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetListeningModeCycle -> buildSettingsMessage(AapControlId.LISTENING_MODE_CONFIGS.value, command.modeMask and 0x0F)
|
||||
is AapCommand.SetAllowOffOption -> buildSettingsMessage(AapControlId.ALLOW_OFF_OPTION.value, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetStemConfig -> buildSettingsMessage(AapControlId.RAW_GESTURES_CONFIG.value, command.claimedPressMask and 0x0F)
|
||||
is AapCommand.SetSleepDetection -> buildSettingsMessage(AapControlId.SLEEP_DETECTION.value, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetDynamicEndOfCharge -> buildSettingsMessage(AapControlId.DYNAMIC_END_OF_CHARGE.value, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetDeviceName -> buildRenameMessage(command.name)
|
||||
}
|
||||
|
||||
override fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? {
|
||||
// Primary pod identity (push-only, fires on mic/primary swap)
|
||||
if (message.commandType == CMD_PRIMARY_POD) {
|
||||
if (message.commandType == AapMessageType.BUD_ROLE.value) {
|
||||
if (message.payload.size < 4) return null
|
||||
val podId = message.payload[0].toInt() and 0xFF
|
||||
// Validate known fixed bytes: [podId] 00 [00|01] [00|01]
|
||||
@@ -136,7 +121,7 @@ class DefaultAapDeviceProfile(
|
||||
}
|
||||
|
||||
// Ear detection is a separate command type (push-only from device)
|
||||
if (message.commandType == CMD_EAR_DETECTION) {
|
||||
if (message.commandType == AapMessageType.EAR_DETECTION.value) {
|
||||
if (message.payload.size < 2) return null
|
||||
return AapSetting.EarDetection::class to AapSetting.EarDetection(
|
||||
primaryPod = decodePodPlacement(message.payload[0].toInt() and 0xFF),
|
||||
@@ -145,7 +130,7 @@ class DefaultAapDeviceProfile(
|
||||
}
|
||||
|
||||
// Connected devices list (push-only from device)
|
||||
if (message.commandType == CMD_CONNECTED_DEVICES) {
|
||||
if (message.commandType == AapMessageType.CONNECTED_DEVICES.value) {
|
||||
if (message.payload.size < 3) return null
|
||||
val count = message.payload[2].toInt() and 0xFF
|
||||
val devices = mutableListOf<AapSetting.ConnectedDevices.ConnectedDevice>()
|
||||
@@ -161,7 +146,7 @@ class DefaultAapDeviceProfile(
|
||||
}
|
||||
|
||||
// Audio source tracking (push-only from device)
|
||||
if (message.commandType == CMD_AUDIO_SOURCE) {
|
||||
if (message.commandType == AapMessageType.AUDIO_SOURCE.value) {
|
||||
if (message.payload.size < 7) return null
|
||||
val mac = (0 until 6).map { "%02X".format(message.payload[it]) }.joinToString(":")
|
||||
val typeValue = message.payload[6].toInt() and 0xFF
|
||||
@@ -173,8 +158,13 @@ class DefaultAapDeviceProfile(
|
||||
return AapSetting.AudioSource::class to AapSetting.AudioSource(mac, type)
|
||||
}
|
||||
|
||||
// EQ data (push-only from device)
|
||||
if (message.commandType == CMD_EQ_DATA) {
|
||||
// 0x53 is "PME Config" per the Wireshark AAP dissector — Personal Medical
|
||||
// Equipment (cf. PPE), i.e. the iOS 18.1+ hearing-aid profile on AirPods
|
||||
// Pro 2. Decoded verbatim as 4 × 8 Float32 (per-ear × per-profile band
|
||||
// gains); stock firmware reports all-zero until the user runs Apple's
|
||||
// Hearing Test. 0x54 "Set Band Edges" is a neighbouring opcode with a
|
||||
// different payload — not decoded here.
|
||||
if (message.commandType == AapMessageType.PME_CONFIG.value) {
|
||||
if (message.payload.size < 6 + 128) return null
|
||||
val sets = mutableListOf<List<Float>>()
|
||||
var offset = 6 // skip header
|
||||
@@ -190,92 +180,105 @@ class DefaultAapDeviceProfile(
|
||||
}
|
||||
sets.add(bands)
|
||||
}
|
||||
return AapSetting.EqBands::class to AapSetting.EqBands(sets)
|
||||
return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets)
|
||||
}
|
||||
|
||||
// Conversation Awareness State is a separate command type (push-only)
|
||||
if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) {
|
||||
if (message.commandType == AapMessageType.CONVERSATIONAL_AWARENESS.value) {
|
||||
if (message.payload.isEmpty()) return null
|
||||
val value = message.payload[0].toInt() and 0xFF
|
||||
val speaking = value == 0x01
|
||||
return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking)
|
||||
}
|
||||
|
||||
if (message.commandType != CMD_SETTINGS) return null
|
||||
if (message.commandType != AapMessageType.CONTROL.value) return null
|
||||
if (message.payload.size < 2) return null
|
||||
|
||||
val settingId = message.payload[0].toInt() and 0xFF
|
||||
val value = message.payload[1].toInt() and 0xFF
|
||||
|
||||
return when (settingId) {
|
||||
SETTING_ANC_MODE -> {
|
||||
AapControlId.LISTEN_MODE.value -> {
|
||||
val mode = decodeAncMode(value) ?: return null
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(current = mode, supported = supportedAncModes)
|
||||
}
|
||||
SETTING_CONVERSATIONAL_AWARENESS -> {
|
||||
AapControlId.CONVERSATION_DETECT.value -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled)
|
||||
}
|
||||
SETTING_PRESS_SPEED -> {
|
||||
AapControlId.DOUBLE_CLICK_INTERVAL.value -> {
|
||||
val speed = AapSetting.PressSpeed.Value.fromWire(value) ?: return null
|
||||
AapSetting.PressSpeed::class to AapSetting.PressSpeed(speed)
|
||||
}
|
||||
SETTING_PRESS_HOLD_DURATION -> {
|
||||
AapControlId.CLICK_AND_HOLD_INTERVAL.value -> {
|
||||
val duration = AapSetting.PressHoldDuration.Value.fromWire(value) ?: return null
|
||||
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(duration)
|
||||
}
|
||||
SETTING_NC_ONE_AIRPOD -> {
|
||||
AapControlId.ONE_BUD_ANC_MODE.value -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.NcWithOneAirPod::class to AapSetting.NcWithOneAirPod(enabled)
|
||||
}
|
||||
SETTING_TONE_VOLUME -> {
|
||||
AapControlId.CHIME_VOLUME.value -> {
|
||||
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = value)
|
||||
}
|
||||
SETTING_VOLUME_SWIPE_LENGTH -> {
|
||||
AapControlId.VOLUME_SWIPE_INTERVAL.value -> {
|
||||
val length = AapSetting.VolumeSwipeLength.Value.fromWire(value) ?: return null
|
||||
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(length)
|
||||
}
|
||||
SETTING_END_CALL_MUTE_MIC -> {
|
||||
AapControlId.CALL_MANAGEMENT_CONFIG.value -> {
|
||||
decodeEndCallMuteMic(message.payload)
|
||||
}
|
||||
SETTING_VOLUME_SWIPE -> {
|
||||
AapControlId.VOLUME_SWIPE_MODE.value -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled)
|
||||
}
|
||||
SETTING_PERSONALIZED_VOLUME -> {
|
||||
AapControlId.ADAPTIVE_VOLUME.value -> {
|
||||
// Wireshark calls 0x26 "Adaptive Volume"; CAPod ships the user-facing name
|
||||
// "Personalized Volume" because that matches the iOS Settings.app label.
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.PersonalizedVolume::class to AapSetting.PersonalizedVolume(enabled)
|
||||
}
|
||||
SETTING_ADAPTIVE_AUDIO_NOISE -> {
|
||||
AapControlId.AUTO_ANC_STRENGTH.value -> {
|
||||
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = 100 - value.coerceIn(0, 100))
|
||||
}
|
||||
SETTING_MICROPHONE_MODE -> {
|
||||
AapControlId.MIC_MODE.value -> {
|
||||
val mode = AapSetting.MicrophoneMode.Mode.fromWire(value) ?: return null
|
||||
AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode)
|
||||
}
|
||||
SETTING_EAR_DETECTION_ENABLED -> {
|
||||
AapControlId.IN_EAR_DETECTION.value -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled)
|
||||
}
|
||||
SETTING_LISTENING_MODE_CYCLE -> {
|
||||
AapControlId.LISTENING_MODE_CONFIGS.value -> {
|
||||
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = value)
|
||||
}
|
||||
SETTING_ALLOW_OFF_OPTION -> {
|
||||
AapControlId.ALLOW_OFF_OPTION.value -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled)
|
||||
}
|
||||
SETTING_STEM_CONFIG -> {
|
||||
AapControlId.RAW_GESTURES_CONFIG.value -> {
|
||||
AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = value)
|
||||
}
|
||||
SETTING_SLEEP_DETECTION -> {
|
||||
AapControlId.SLEEP_DETECTION.value -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled)
|
||||
}
|
||||
SETTING_IN_CASE_TONE -> {
|
||||
AapControlId.DYNAMIC_END_OF_CHARGE.value -> {
|
||||
// Apple's "Optimized Charge Limit" — Pro 3 pushes this on connect as value 0x01
|
||||
// (enabled). decodeAppleBool rejects anything that isn't a confirmed bool so
|
||||
// unknown encodings fall through to UnknownSetting logging rather than being
|
||||
// coerced to false.
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled)
|
||||
}
|
||||
AapControlId.IN_CASE_TONE.value -> {
|
||||
// Decoded internally (never exposed in UI) to keep lastMessageAt fresh.
|
||||
// Originally labeled "Charging Sounds" — the real case tones are controlled
|
||||
// over ATT, not here; the actual effect of this setting is unknown.
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled)
|
||||
}
|
||||
in UNCONFIRMED_SETTING_IDS -> {
|
||||
in UNMAPPED_SETTING_IDS -> {
|
||||
AapSetting.UnknownSetting::class to AapSetting.UnknownSetting(
|
||||
settingId = settingId,
|
||||
rawValue = value,
|
||||
@@ -286,7 +289,7 @@ class DefaultAapDeviceProfile(
|
||||
}
|
||||
|
||||
override fun decodeBattery(message: AapMessage): Map<AapPodState.BatteryType, AapPodState.Battery>? {
|
||||
if (message.commandType != CMD_BATTERY) return null
|
||||
if (message.commandType != AapMessageType.BATTERY_INFO.value) return null
|
||||
val payload = message.payload
|
||||
if (payload.isEmpty()) return null
|
||||
|
||||
@@ -328,7 +331,7 @@ class DefaultAapDeviceProfile(
|
||||
)
|
||||
|
||||
override fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult? {
|
||||
if (message.commandType != CMD_PRIVATE_KEYS_RESPONSE) return null
|
||||
if (message.commandType != AapMessageType.MAGIC_KEYS.value) return null
|
||||
val payload = message.payload
|
||||
if (payload.isEmpty()) return null
|
||||
|
||||
@@ -360,26 +363,31 @@ class DefaultAapDeviceProfile(
|
||||
}
|
||||
|
||||
override fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo? {
|
||||
if (message.commandType != CMD_DEVICE_INFO) return null
|
||||
if (message.commandType != AapMessageType.INFORMATION.value) return null
|
||||
if (message.payload.size < 10) return null
|
||||
|
||||
// Device info payload contains null-terminated UTF-8 strings
|
||||
// Format: [binary header] [NUL-delimited strings...]
|
||||
val strings = parseNullTerminatedStrings(message.payload)
|
||||
if (strings.size < 4) return null
|
||||
val parsed = parseDeviceInfoPayload(message.payload) ?: return null
|
||||
if (parsed.strings.size < 4) return null
|
||||
|
||||
val activeFirmware = parsed.strings.getOrElse(4) { "" }
|
||||
val pendingFirmware = parsed.strings.getOrNull(5)?.takeIf { it.isNotBlank() && it != activeFirmware }
|
||||
|
||||
return AapDeviceInfo(
|
||||
name = strings.getOrElse(0) { "" },
|
||||
modelNumber = strings.getOrElse(1) { "" },
|
||||
manufacturer = strings.getOrElse(2) { "" },
|
||||
serialNumber = strings.getOrElse(3) { "" },
|
||||
firmwareVersion = strings.getOrElse(4) { "" },
|
||||
// Segments [5]=firmware dup, [6]=protocol version, [7]=updater app ID — skipped
|
||||
// Segments [8] and [9] are individual earbud serials (observed on Pro 1, Pro 2, Pro 3)
|
||||
leftEarbudSerial = strings.getOrNull(8)?.takeIf { it.isNotBlank() },
|
||||
rightEarbudSerial = strings.getOrNull(9)?.takeIf { it.isNotBlank() },
|
||||
// Segment [10] is the build number (e.g. "8454624")
|
||||
buildNumber = strings.getOrNull(10)?.takeIf { it.isNotBlank() },
|
||||
name = parsed.strings.getOrElse(0) { "" },
|
||||
modelNumber = parsed.strings.getOrElse(1) { "" },
|
||||
manufacturer = parsed.strings.getOrElse(2) { "" },
|
||||
serialNumber = parsed.strings.getOrElse(3) { "" },
|
||||
firmwareVersion = activeFirmware,
|
||||
firmwareVersionPending = pendingFirmware,
|
||||
hardwareVersion = parsed.strings.getOrNull(6)?.takeIf { it.isNotBlank() },
|
||||
eaProtocolName = parsed.strings.getOrNull(7)?.takeIf { it.isNotBlank() },
|
||||
leftEarbudSerial = parsed.strings.getOrNull(8)?.takeIf { it.isNotBlank() },
|
||||
rightEarbudSerial = parsed.strings.getOrNull(9)?.takeIf { it.isNotBlank() },
|
||||
marketingVersion = parsed.strings.getOrNull(10)?.takeIf { it.isNotBlank() },
|
||||
leftEarbudUuid = parsed.leftEarbudUuid,
|
||||
rightEarbudUuid = parsed.rightEarbudUuid,
|
||||
leftEarbudFirstPaired = parsed.leftEarbudFirstPaired,
|
||||
rightEarbudFirstPaired = parsed.rightEarbudFirstPaired,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -445,7 +453,7 @@ class DefaultAapDeviceProfile(
|
||||
return byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00,
|
||||
0x09, 0x00,
|
||||
SETTING_END_CALL_MUTE_MIC.toByte(), 0x20,
|
||||
AapControlId.CALL_MANAGEMENT_CONFIG.value.toByte(), 0x20,
|
||||
combined.toByte(),
|
||||
0x00, 0x00,
|
||||
)
|
||||
@@ -480,13 +488,49 @@ class DefaultAapDeviceProfile(
|
||||
}
|
||||
|
||||
override fun decodeStemPress(message: AapMessage): StemPressEvent? {
|
||||
if (message.commandType != CMD_STEM_PRESS) return null
|
||||
if (message.commandType != AapMessageType.STEM_PRESS.value) return null
|
||||
if (message.payload.size < 2) return null
|
||||
val pressType = StemPressEvent.PressType.fromWire(message.payload[0].toInt() and 0xFF) ?: return null
|
||||
val bud = StemPressEvent.Bud.fromWire(message.payload[1].toInt() and 0xFF) ?: return null
|
||||
return StemPressEvent(pressType, bud)
|
||||
}
|
||||
|
||||
/**
|
||||
* Case Info probing is allowlisted to models observed to respond. Pro 3 is the
|
||||
* confirmed first entry; other models can be added once captures verify they
|
||||
* reply to 0x22 with a 0x23 payload.
|
||||
*/
|
||||
override fun encodeCaseInfoRequest(): ByteArray? {
|
||||
if (model !in CASE_INFO_ALLOWLIST) return null
|
||||
// Fire-and-forget 6-byte template, matching the private key request shape
|
||||
// (header + command type with no payload).
|
||||
return byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00,
|
||||
AapMessageType.CASE_INFO_REQUEST.value.toByte(), 0x00,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort decoder. Per the dissector, the payload contains a mix of
|
||||
* binary VID/PID/color bytes plus a NUL-delimited name string. Without
|
||||
* more captures we preserve the raw payload and leave individual fields
|
||||
* null — future iteration can map specific offsets.
|
||||
*/
|
||||
override fun decodeCaseInfo(message: AapMessage): AapCaseInfo? {
|
||||
if (message.commandType != AapMessageType.CASE_INFO.value) return null
|
||||
return AapCaseInfo(rawPayload = message.payload.copyOf())
|
||||
}
|
||||
|
||||
override fun decodeSleepEvent(message: AapMessage): AapSleepEvent? {
|
||||
if (message.commandType != AapMessageType.SLEEP_DETECTION_UPDATE.value) return null
|
||||
return AapSleepEvent(rawPayload = message.payload.copyOf())
|
||||
}
|
||||
|
||||
override fun decodeDynamicEndOfChargeEvent(message: AapMessage): AapDynamicEndOfChargeEvent? {
|
||||
if (message.commandType != AapMessageType.DYNAMIC_END_OF_CHARGE.value) return null
|
||||
return AapDynamicEndOfChargeEvent(rawPayload = message.payload.copyOf())
|
||||
}
|
||||
|
||||
private fun buildRenameMessage(name: String): ByteArray {
|
||||
// Uses the opcode 0x1A format from the LibrePods AAP docs + Linux implementation.
|
||||
// Verified end-to-end on AirPods Pro 2 USB-C (firmware 81.2675...): the device accepts
|
||||
@@ -510,31 +554,89 @@ class DefaultAapDeviceProfile(
|
||||
) + nameBytes
|
||||
}
|
||||
|
||||
private fun parseNullTerminatedStrings(data: ByteArray): List<String> {
|
||||
// Skip binary header until the first printable byte (device name always starts
|
||||
// with a printable character). This skips the length/type prefix bytes.
|
||||
var headerEnd = 0
|
||||
while (headerEnd < data.size) {
|
||||
val b = data[headerEnd].toInt() and 0xFF
|
||||
if (b in 0x20..0x7E) break
|
||||
headerEnd++
|
||||
/**
|
||||
* Heuristic binary-prefix skip: walk forward until we hit a printable byte.
|
||||
* The real header schema is `[02 XX 00 04 00]` in every capture to date, but
|
||||
* documenting that without an authoritative source would be guessing. The
|
||||
* heuristic breaks only for device names that start with a non-printable
|
||||
* byte \u2014 theoretically emoji names (UTF-8 first byte 0xF0-0xF4, outside the
|
||||
* printable range) could misalign here. None observed in real captures so far.
|
||||
*/
|
||||
private fun skipBinaryHeader(data: ByteArray): Int {
|
||||
var offset = 0
|
||||
while (offset < data.size) {
|
||||
val b = data[offset].toInt() and 0xFF
|
||||
if (b in 0x20..0x7E) return offset
|
||||
offset++
|
||||
}
|
||||
if (headerEnd >= data.size) return emptyList()
|
||||
|
||||
// Split on NUL bytes and decode each segment as UTF-8.
|
||||
// This handles device names with non-ASCII characters (e.g. curly quotes
|
||||
// in "Matthias\u2019s AirPods Pro") that the old ASCII-only scanner would break on.
|
||||
val strings = mutableListOf<String>()
|
||||
var i = headerEnd
|
||||
while (i < data.size) {
|
||||
// Skip NUL separators
|
||||
while (i < data.size && data[i] == 0x00.toByte()) i++
|
||||
if (i >= data.size) break
|
||||
|
||||
val segStart = i
|
||||
while (i < data.size && data[i] != 0x00.toByte()) i++
|
||||
strings.add(String(data, segStart, i - segStart, Charsets.UTF_8))
|
||||
}
|
||||
return strings
|
||||
return data.size
|
||||
}
|
||||
|
||||
private fun parseDeviceInfoPayload(data: ByteArray): DeviceInfoSegments? {
|
||||
var offset = skipBinaryHeader(data)
|
||||
if (offset >= data.size) return null
|
||||
|
||||
// Segments 0..10: NUL-delimited UTF-8 strings. UTF-8 means non-ASCII (e.g.
|
||||
// curly quotes in "Matthias's AirPods Pro") decodes correctly.
|
||||
val strings = mutableListOf<String>()
|
||||
while (strings.size < 11 && offset < data.size) {
|
||||
while (offset < data.size && data[offset] == 0x00.toByte()) offset++
|
||||
if (offset >= data.size) break
|
||||
val segStart = offset
|
||||
while (offset < data.size && data[offset] != 0x00.toByte()) offset++
|
||||
strings.add(String(data, segStart, offset - segStart, Charsets.UTF_8))
|
||||
}
|
||||
|
||||
// Skip the NUL that terminated segment 10 (if present) before the UUID blob.
|
||||
while (offset < data.size && data[offset] == 0x00.toByte()) offset++
|
||||
|
||||
// Segments 11 and 12: fixed 17-byte UUIDs (NOT NUL-terminated, may contain 0x00).
|
||||
// Best-effort: if payload is shorter, both UUIDs are null.
|
||||
val leftUuid: ByteArray? = if (offset + UUID_LEN <= data.size) {
|
||||
val slice = data.copyOfRange(offset, offset + UUID_LEN)
|
||||
offset += UUID_LEN
|
||||
slice
|
||||
} else null
|
||||
|
||||
val rightUuid: ByteArray? = if (offset + UUID_LEN <= data.size) {
|
||||
val slice = data.copyOfRange(offset, offset + UUID_LEN)
|
||||
offset += UUID_LEN
|
||||
slice
|
||||
} else null
|
||||
|
||||
// Segments 13 and 14: NUL-delimited ASCII-decimal epoch seconds (e.g. "1697480211").
|
||||
val trailingStrings = mutableListOf<String>()
|
||||
while (trailingStrings.size < 2 && offset < data.size) {
|
||||
while (offset < data.size && data[offset] == 0x00.toByte()) offset++
|
||||
if (offset >= data.size) break
|
||||
val segStart = offset
|
||||
while (offset < data.size && data[offset] != 0x00.toByte()) offset++
|
||||
trailingStrings.add(String(data, segStart, offset - segStart, Charsets.UTF_8))
|
||||
}
|
||||
|
||||
val leftPaired = trailingStrings.getOrNull(0).parseEpochSecondsOrNull()
|
||||
val rightPaired = trailingStrings.getOrNull(1).parseEpochSecondsOrNull()
|
||||
|
||||
return DeviceInfoSegments(
|
||||
strings = strings,
|
||||
leftEarbudUuid = leftUuid,
|
||||
rightEarbudUuid = rightUuid,
|
||||
leftEarbudFirstPaired = leftPaired,
|
||||
rightEarbudFirstPaired = rightPaired,
|
||||
)
|
||||
}
|
||||
|
||||
private fun String?.parseEpochSecondsOrNull(): java.time.Instant? {
|
||||
if (this.isNullOrBlank()) return null
|
||||
val seconds = this.toLongOrNull() ?: return null
|
||||
return runCatching { java.time.Instant.ofEpochSecond(seconds) }.getOrNull()
|
||||
}
|
||||
|
||||
private data class DeviceInfoSegments(
|
||||
val strings: List<String>,
|
||||
val leftEarbudUuid: ByteArray?,
|
||||
val rightEarbudUuid: ByteArray?,
|
||||
val leftEarbudFirstPaired: java.time.Instant?,
|
||||
val rightEarbudFirstPaired: java.time.Instant?,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package eu.darken.capod.pods.core.apple.ble
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.bluetooth.logSummary
|
||||
import eu.darken.capod.common.bluetooth.redactedForLogs
|
||||
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
|
||||
@@ -119,7 +120,7 @@ class AppleFactory @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
factory.create(
|
||||
val device = factory.create(
|
||||
scanResult = scanResult,
|
||||
payload = payload,
|
||||
meta = ApplePods.AppleMeta(
|
||||
@@ -127,6 +128,18 @@ class AppleFactory @Inject constructor(
|
||||
profile = profile,
|
||||
),
|
||||
)
|
||||
|
||||
log(TAG, DEBUG) {
|
||||
val rawHex = scanResult.manufacturerSpecificData.entries.joinToString("; ") { (id, bytes) ->
|
||||
"$id:${bytes.joinToString(" ") { "%02X".format(it.toInt() and 0xFF) }}"
|
||||
}
|
||||
val publicHex = payload.public.data.joinToString(" ") { "%02X".format(it.toInt()) }
|
||||
val privateHex = payload.private?.data?.joinToString(" ") { "%02X".format(it.toInt()) } ?: "-"
|
||||
"Apple decoded: model=${device.model}, addr=${scanResult.address.redactedForLogs()}, " +
|
||||
"irkMatch=$isIrkMatch, raw=[$rawHex], public=[$publicHex], private=[$privateHex]"
|
||||
}
|
||||
|
||||
device
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -3,7 +3,6 @@ package eu.darken.capod.pods.core.apple.ble
|
||||
import dagger.Reusable
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.bluetooth.logSummary
|
||||
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.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
@@ -26,7 +25,7 @@ class PodFactory @Inject constructor(
|
||||
device = unknownFactory.create(scanResult)
|
||||
}
|
||||
|
||||
log(TAG, DEBUG) { "Pod created: ${device.logSummary()}" }
|
||||
log(TAG, VERBOSE) { "Pod created: ${device.logSummary()}" }
|
||||
return Result(scanResult = scanResult, device = device)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,14 +37,14 @@ data class ProximityMessage(
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.ERROR
|
||||
) { "Failed to decrypt $message with ${key.toByteString()}\n${e.asLog()}" }
|
||||
) { "Failed to decrypt $message\n${e.asLog()}" }
|
||||
null
|
||||
}
|
||||
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.VERBOSE
|
||||
) { "Decrypted $message with ${key.toByteString()} to ${decryptedData?.toByteString()}" }
|
||||
) { "Decrypted $message to ${decryptedData?.toByteString()}" }
|
||||
|
||||
if (decryptedData == null || decryptedData.size != 16) return null
|
||||
|
||||
|
||||
@@ -55,4 +55,19 @@ data class AppleDeviceProfile(
|
||||
showPopUpOnCaseOpen = showPopUpOnCaseOpen,
|
||||
showPopUpOnConnection = showPopUpOnConnection,
|
||||
)
|
||||
|
||||
override fun toString(): String = "AppleDeviceProfile(" +
|
||||
"id=$id, label=$label, priority=$priority, model=$model, " +
|
||||
"minimumSignalQuality=$minimumSignalQuality, " +
|
||||
"identityKey=${if (identityKey == null) "null" else "<redacted>"}, " +
|
||||
"encryptionKey=${if (encryptionKey == null) "null" else "<redacted>"}, " +
|
||||
"address=$address, autoPause=$autoPause, autoPlay=$autoPlay, " +
|
||||
"onePodMode=$onePodMode, autoConnect=$autoConnect, " +
|
||||
"autoConnectCondition=$autoConnectCondition, " +
|
||||
"showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " +
|
||||
"showPopUpOnConnection=$showPopUpOnConnection, " +
|
||||
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
|
||||
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
|
||||
"stemActions=$stemActions" +
|
||||
")"
|
||||
}
|
||||
@@ -65,6 +65,30 @@ class DeviceProfilesRepo @Inject constructor(
|
||||
if (!settings.reactionMigrationDone.valueBlocking) {
|
||||
migrateLegacyReactions()
|
||||
}
|
||||
|
||||
detectLegacyReactionData()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether this install ever wrote to the pre-migration global reaction DataStore.
|
||||
* The legacy reader is a read-only probe; the legacy keys persist on old installs even
|
||||
* after [migrateLegacyReactions] has run, so this check works retroactively for users who
|
||||
* already migrated. Used by the Overview hint to target only existing users who actually
|
||||
* configured reactions before the per-device move.
|
||||
*/
|
||||
private suspend fun detectLegacyReactionData() {
|
||||
val hadData = try {
|
||||
val legacy = LegacyReactionSettingsReader(context, json).read()
|
||||
legacy.autoPause || legacy.autoPlay || legacy.autoConnect ||
|
||||
legacy.showPopUpOnCaseOpen || legacy.showPopUpOnConnection ||
|
||||
legacy.onePodMode
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to detect legacy reaction data: ${e.message}" }
|
||||
false
|
||||
}
|
||||
if (hadData != settings.hadLegacyReactionData.valueBlocking) {
|
||||
settings.hadLegacyReactionData.valueBlocking = hadData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +130,8 @@ class DeviceProfilesRepo @Inject constructor(
|
||||
|
||||
val profiles: Flow<List<DeviceProfile>> = settings.profiles.flow.map { it.profiles }
|
||||
|
||||
val hadLegacyReactionData: Flow<Boolean> = settings.hadLegacyReactionData.flow
|
||||
|
||||
suspend fun addProfile(profile: DeviceProfile, addFirst: Boolean = false) = mutex.withLock {
|
||||
val currentContainer = settings.profiles.valueBlocking
|
||||
checkAddressUniqueness(profile, currentContainer.profiles)
|
||||
|
||||
@@ -34,5 +34,6 @@ class DeviceProfilesSettings @Inject constructor(
|
||||
val singleToMultiMigrationDone = dataStore.createValue("profiles.migration.v2.done", false)
|
||||
val defaultProfileCreated = dataStore.createValue("profiles.default.v2.created", false)
|
||||
val reactionMigrationDone = dataStore.createValue("profiles.reactions.migration.done", false)
|
||||
val hadLegacyReactionData = dataStore.createValue("profiles.reactions.had_legacy_data", false)
|
||||
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -261,7 +262,7 @@ private fun ProfileRow(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
|
||||
@@ -2,6 +2,8 @@ package eu.darken.capod.reaction.core.playpause
|
||||
|
||||
import eu.darken.capod.common.MediaControl
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
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
|
||||
@@ -11,13 +13,15 @@ import eu.darken.capod.common.flow.withPrevious
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||
import eu.darken.capod.reaction.core.playpause.PlayPause.Companion.PAUSE_DEBOUNCE_SAMPLES
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -30,6 +34,9 @@ class PlayPause @Inject constructor(
|
||||
|
||||
fun monitor() = run {
|
||||
var pendingPlayConfirmation: PendingPlayConfirmation? = null
|
||||
var pendingPauseDebounce: PendingPauseDebounce? = null
|
||||
var hasLastMonitorKey = false
|
||||
var lastMonitorKey: PlayPauseMonitorKey? = null
|
||||
|
||||
deviceMonitor.primaryDevice()
|
||||
.map { device -> device?.reactions?.let { it.autoPlay || it.autoPause } == true }
|
||||
@@ -39,12 +46,18 @@ class PlayPause @Inject constructor(
|
||||
bluetoothManager.connectedDevices
|
||||
} else {
|
||||
pendingPlayConfirmation = null
|
||||
pendingPauseDebounce = null
|
||||
hasLastMonitorKey = false
|
||||
lastMonitorKey = null
|
||||
emptyFlow()
|
||||
}
|
||||
}
|
||||
.flatMapLatest { connected ->
|
||||
if (connected.isEmpty()) {
|
||||
pendingPlayConfirmation = null
|
||||
pendingPauseDebounce = null
|
||||
hasLastMonitorKey = false
|
||||
lastMonitorKey = null
|
||||
log(TAG) { "No known devices connected." }
|
||||
emptyFlow()
|
||||
} else {
|
||||
@@ -53,9 +66,20 @@ class PlayPause @Inject constructor(
|
||||
}
|
||||
}
|
||||
// Cache persistence can update battery timestamps without changing any reaction-relevant state.
|
||||
.distinctUntilChangedBy { it?.toPlayPauseMonitorKey() }
|
||||
.filter { device ->
|
||||
val key = device?.toPlayPauseMonitorKey()
|
||||
val shouldEmit = !hasLastMonitorKey ||
|
||||
key != lastMonitorKey ||
|
||||
device?.isPauseDebounceResetCandidate(pendingPauseDebounce) == true
|
||||
|
||||
if (shouldEmit) {
|
||||
hasLastMonitorKey = true
|
||||
lastMonitorKey = key
|
||||
}
|
||||
shouldEmit
|
||||
}
|
||||
.onEach { device ->
|
||||
log(TAG, VERBOSE) { "Post-distinct: profileId=${device?.profileId}" }
|
||||
log(TAG, VERBOSE) { "Post-monitor-filter: profileId=${device?.profileId}" }
|
||||
}
|
||||
.withPrevious()
|
||||
.filter { (previous, current) ->
|
||||
@@ -63,7 +87,13 @@ class PlayPause @Inject constructor(
|
||||
// Use profileId (stable across BLE address rotations) rather than BLE identifier.
|
||||
// Only profiled devices reach this point (outer gate requires profile.autoPlay/autoPause).
|
||||
val match = previous.profileId != null && previous.profileId == current.profileId
|
||||
if (!match) log(TAG, WARN) { "Main device switched, skipping reaction." }
|
||||
if (!match) {
|
||||
log(TAG, WARN) { "Main device switched, skipping reaction." }
|
||||
if (pendingPauseDebounce != null) {
|
||||
log(TAG, DEBUG) { "Pause debounce reset: profile change" }
|
||||
pendingPauseDebounce = null
|
||||
}
|
||||
}
|
||||
match
|
||||
}
|
||||
.onEach { (previous, current) ->
|
||||
@@ -76,6 +106,26 @@ class PlayPause @Inject constructor(
|
||||
if (reactions == null) {
|
||||
log(TAG, VERBOSE) { "No reactions on current device, skipping reaction" }
|
||||
pendingPlayConfirmation = null
|
||||
if (pendingPauseDebounce != null) {
|
||||
log(TAG, DEBUG) { "Pause debounce reset: no reactions on device" }
|
||||
pendingPauseDebounce = null
|
||||
}
|
||||
return@onEach
|
||||
}
|
||||
|
||||
// Skip the reaction when previous was a no-live-evidence emission (cache-only
|
||||
// baseline emitted at process start, or after a >20s BLE gap that evicted the
|
||||
// device from the live cache). PodDevice.isBeingWorn returns null for those,
|
||||
// and toEarDetectionState() coerces null -> false, which would produce a
|
||||
// fake "not-worn -> worn" transition the moment live BLE arrives — firing
|
||||
// an unwanted autoPlay on app start while the user is wearing the pods.
|
||||
if (previous?.earDetectionSource() == EarDetectionSource.NO_LIVE_BLE) {
|
||||
log(TAG, VERBOSE) { "Previous emission has no live evidence; skipping reaction." }
|
||||
pendingPlayConfirmation = null
|
||||
if (pendingPauseDebounce != null) {
|
||||
log(TAG, DEBUG) { "Pause debounce reset: previous emission lacked live evidence" }
|
||||
pendingPauseDebounce = null
|
||||
}
|
||||
return@onEach
|
||||
}
|
||||
|
||||
@@ -109,6 +159,10 @@ class PlayPause @Inject constructor(
|
||||
else -> {
|
||||
log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" }
|
||||
pendingPlayConfirmation = null
|
||||
if (pendingPauseDebounce != null) {
|
||||
log(TAG, DEBUG) { "Pause debounce reset: device lost ear detection" }
|
||||
pendingPauseDebounce = null
|
||||
}
|
||||
return@onEach
|
||||
}
|
||||
}
|
||||
@@ -116,6 +170,8 @@ class PlayPause @Inject constructor(
|
||||
val isCurrentlyPlaying = mediaControl.isPlaying
|
||||
val wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap
|
||||
|
||||
val source = current.earDetectionSource()
|
||||
|
||||
// Evaluate what action to take
|
||||
val rawDecision = evaluatePlayPauseAction(
|
||||
previous = prevState,
|
||||
@@ -125,11 +181,18 @@ class PlayPause @Inject constructor(
|
||||
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
|
||||
)
|
||||
|
||||
// BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources.
|
||||
// Trusted sources (AAP, BLE_IRK_MATCH) skip staging — symmetric to the
|
||||
// pause debounce, which also skips for these sources. Without this gate,
|
||||
// an IRK-matched device with no live AAP would stage a BLE-only confirmation
|
||||
// that never confirms (the second worn sample has no freshness on the
|
||||
// monitor key for IRK_MATCH so it gets collapsed).
|
||||
val shouldStageBleOnlyPlay = rawDecision.shouldPlay &&
|
||||
reactions.autoPlay &&
|
||||
!reactions.onePodMode &&
|
||||
current.hasDualPods &&
|
||||
current.aap?.aapEarDetection == null
|
||||
(source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
|
||||
source == EarDetectionSource.BLE_ANONYMOUS)
|
||||
|
||||
val confirmation = applyBleOnlyPlayConfirmation(
|
||||
pending = pendingPlayConfirmation,
|
||||
@@ -151,7 +214,36 @@ class PlayPause @Inject constructor(
|
||||
log(TAG, VERBOSE) { "BLE-only autoplay confirmed by a follow-up state update" }
|
||||
}
|
||||
|
||||
val decision = confirmation.decision
|
||||
val debounceResult = applyPauseDebounce(
|
||||
pending = pendingPauseDebounce,
|
||||
profileId = current.profileId,
|
||||
source = source,
|
||||
rawDecision = confirmation.decision,
|
||||
currentState = currState,
|
||||
autoPauseEnabled = reactions.autoPause,
|
||||
)
|
||||
pendingPauseDebounce = debounceResult.pending
|
||||
|
||||
when (debounceResult.event) {
|
||||
PauseDebounceEvent.STARTED -> log(TAG, DEBUG) {
|
||||
"Pause debounce started: source=$source, initialPodCount=${debounceResult.pending?.initialPodCount}, " +
|
||||
"remaining=${debounceResult.pending?.confirmationsRemaining}"
|
||||
}
|
||||
PauseDebounceEvent.ADVANCED -> log(TAG, DEBUG) {
|
||||
"Pause debounce advanced: remaining=${debounceResult.pending?.confirmationsRemaining}, " +
|
||||
"currentPodCount=${currState.podCount}"
|
||||
}
|
||||
PauseDebounceEvent.RESET -> log(TAG, DEBUG) {
|
||||
"Pause debounce reset: source=$source, currentPodCount=${currState.podCount}, " +
|
||||
"rawShouldPlay=${confirmation.decision.shouldPlay}"
|
||||
}
|
||||
PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) {
|
||||
"Pause debounce committed: source=$source confirmed pause"
|
||||
}
|
||||
PauseDebounceEvent.NONE -> {}
|
||||
}
|
||||
|
||||
val decision = debounceResult.decision
|
||||
if (decision.usedRecentCapPauseOverride) {
|
||||
log(TAG, VERBOSE) {
|
||||
"Resume override: recent CAP pause window is active, allowing play despite playing=true"
|
||||
@@ -171,8 +263,15 @@ class PlayPause @Inject constructor(
|
||||
}
|
||||
|
||||
decision.shouldPause && reactions.autoPause -> {
|
||||
log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" }
|
||||
mediaControl.sendPause()
|
||||
val pauseSent = mediaControl.sendPause()
|
||||
log(TAG, INFO) {
|
||||
"autoPause triggered: source=$source, " +
|
||||
"wasWorn=${prevState.bothInEar}, isWorn=${currState.bothInEar}, " +
|
||||
"podCount=${prevState.podCount}->${currState.podCount}, " +
|
||||
"aapEar=${current.aap?.aapEarDetection != null}, " +
|
||||
"aapConn=${current.aap?.connectionState}, " +
|
||||
"pauseSent=$pauseSent, reason=${decision.reason}"
|
||||
}
|
||||
}
|
||||
|
||||
decision.shouldPause && !reactions.autoPause -> {
|
||||
@@ -319,6 +418,144 @@ class PlayPause @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample-count debounce for pause decisions when the ear-detection source is an
|
||||
* unauthenticated BLE advertisement.
|
||||
*
|
||||
* RF interference can produce a single corrupt advert that decodes as not-worn,
|
||||
* triggering a false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires
|
||||
* 3 consecutive not-worn samples before firing.
|
||||
*
|
||||
* The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause]
|
||||
* — subsequent samples after the initial detection are not-worn → not-worn, and
|
||||
* [evaluateNormalMode] returns no action for those.
|
||||
*
|
||||
* Confirmation rule: a sample with `currentState.podCount <= pending.initialPodCount`
|
||||
* counts as a confirmation. An *increase* (pod returned) clears pending.
|
||||
*
|
||||
* Trusted sources ([EarDetectionSource.AAP], [EarDetectionSource.BLE_IRK_MATCH]) skip
|
||||
* debounce and pass through [rawDecision] unchanged; any active [pending] is cleared.
|
||||
*/
|
||||
internal fun applyPauseDebounce(
|
||||
pending: PendingPauseDebounce?,
|
||||
profileId: String?,
|
||||
source: EarDetectionSource,
|
||||
rawDecision: PlayPauseDecision,
|
||||
currentState: EarDetectionState,
|
||||
autoPauseEnabled: Boolean,
|
||||
): PauseDebounceResult {
|
||||
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
|
||||
source == EarDetectionSource.BLE_ANONYMOUS
|
||||
|
||||
val activePending = pending?.takeIf { it.profileId == profileId }
|
||||
|
||||
// NO_LIVE_BLE: no fresh evidence anywhere (ble == null, aap absent or no EarDetection).
|
||||
// Suppress shouldPause — a worn → not-worn transition derived from null/cached state is
|
||||
// not real removal evidence. Also clears any active pending since stale samples must not
|
||||
// advance confirmations.
|
||||
if (source == EarDetectionSource.NO_LIVE_BLE) {
|
||||
val event = if (activePending != null) PauseDebounceEvent.RESET else PauseDebounceEvent.NONE
|
||||
return PauseDebounceResult(
|
||||
decision = rawDecision.copy(
|
||||
shouldPause = false,
|
||||
reason = "${rawDecision.reason} (suppressed: no live BLE evidence)",
|
||||
),
|
||||
pending = null,
|
||||
event = event,
|
||||
)
|
||||
}
|
||||
|
||||
// Non-debounce-eligible trusted sources (AAP / BLE_IRK_MATCH) and disabled debounce:
|
||||
// pass raw decision through and clear any active pending. Special case: if a
|
||||
// pending was active and the trusted source still shows the not-worn condition
|
||||
// (currentState.podCount <= initialPodCount and no play decision), commit the
|
||||
// pause now — the trusted source corroborates what BLE-debounce was waiting on.
|
||||
if (!needsDebounce || !autoPauseEnabled || profileId == null) {
|
||||
if (activePending != null && autoPauseEnabled &&
|
||||
currentState.podCount <= activePending.initialPodCount &&
|
||||
!rawDecision.shouldPlay
|
||||
) {
|
||||
return PauseDebounceResult(
|
||||
decision = PlayPauseDecision(
|
||||
shouldPlay = false,
|
||||
shouldPause = true,
|
||||
reason = "Pause confirmed by trusted source ($source) after BLE-debounce",
|
||||
),
|
||||
pending = null,
|
||||
event = PauseDebounceEvent.COMMITTED,
|
||||
)
|
||||
}
|
||||
val event = if (activePending != null) PauseDebounceEvent.RESET else PauseDebounceEvent.NONE
|
||||
return PauseDebounceResult(decision = rawDecision, pending = null, event = event)
|
||||
}
|
||||
|
||||
// First detection: no active pending, raw decision wants to pause → start debounce.
|
||||
if (activePending == null) {
|
||||
if (!rawDecision.shouldPause) {
|
||||
return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.NONE)
|
||||
}
|
||||
if (PAUSE_DEBOUNCE_SAMPLES <= 0) {
|
||||
return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.NONE)
|
||||
}
|
||||
return PauseDebounceResult(
|
||||
decision = rawDecision.copy(
|
||||
shouldPause = false,
|
||||
reason = "${rawDecision.reason} (debouncing, $PAUSE_DEBOUNCE_SAMPLES confirmation(s) needed)",
|
||||
),
|
||||
pending = PendingPauseDebounce(
|
||||
profileId = profileId,
|
||||
initialPodCount = currentState.podCount,
|
||||
confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES,
|
||||
),
|
||||
event = PauseDebounceEvent.STARTED,
|
||||
)
|
||||
}
|
||||
|
||||
// Confirmation phase: pending exists.
|
||||
// Reset cases — checked in order of authority:
|
||||
// 1. Raw decision wants to play → genuine play signal, reset immediately.
|
||||
// 2. Pod count went up → tolerate one rebound sample (corrupt count-up
|
||||
// protection), reset only on the second consecutive count-up.
|
||||
if (rawDecision.shouldPlay) {
|
||||
return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.RESET)
|
||||
}
|
||||
if (currentState.podCount > activePending.initialPodCount) {
|
||||
if (activePending.resetTolerance > 0) {
|
||||
return PauseDebounceResult(
|
||||
decision = rawDecision.copy(
|
||||
shouldPause = false,
|
||||
reason = "Debouncing pause (rebound tolerated)",
|
||||
),
|
||||
pending = activePending.copy(resetTolerance = activePending.resetTolerance - 1),
|
||||
event = PauseDebounceEvent.ADVANCED,
|
||||
)
|
||||
}
|
||||
return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.RESET)
|
||||
}
|
||||
|
||||
// Confirmation: count <= initialPodCount, decrement remaining.
|
||||
val remaining = activePending.confirmationsRemaining - 1
|
||||
if (remaining <= 0) {
|
||||
return PauseDebounceResult(
|
||||
decision = PlayPauseDecision(
|
||||
shouldPlay = false,
|
||||
shouldPause = true,
|
||||
reason = "Debounced pause confirmed (initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
|
||||
),
|
||||
pending = null,
|
||||
event = PauseDebounceEvent.COMMITTED,
|
||||
)
|
||||
}
|
||||
return PauseDebounceResult(
|
||||
decision = rawDecision.copy(
|
||||
shouldPause = false,
|
||||
reason = "Debouncing pause ($remaining confirmation(s) remaining)",
|
||||
),
|
||||
pending = activePending.copy(confirmationsRemaining = remaining),
|
||||
event = PauseDebounceEvent.ADVANCED,
|
||||
)
|
||||
}
|
||||
|
||||
data class EarDetectionState(
|
||||
val leftInEar: Boolean?, // null for single pod devices
|
||||
val rightInEar: Boolean?, // null for single pod devices
|
||||
@@ -389,6 +626,46 @@ class PlayPause @Inject constructor(
|
||||
val stagedConfirmation: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Trust classification for the source of the current ear-detection reading.
|
||||
*
|
||||
* - [AAP]: EarDetection setting from an active AAP (L2CAP) session — error-corrected,
|
||||
* identity-authenticated. Trusted; debounce skipped.
|
||||
* - [BLE_IRK_MATCH]: BLE advertisement whose RPA was verified against this profile's
|
||||
* identity key. Identity-authenticated; debounce skipped.
|
||||
* - [BLE_PROFILE_FALLBACK]: BLE advertisement assigned to a profile via signal-quality
|
||||
* fallback (no IRK match). Could be a stray advert from a nearby pair. Debounced.
|
||||
* - [BLE_ANONYMOUS]: BLE advertisement with no profile match. Filtered out by
|
||||
* [primaryDevice] in production (devices without profileId are dropped before
|
||||
* reaching the reaction layer); kept here defensively in case the upstream filter
|
||||
* changes. Note: [PendingPauseDebounce] is profile-keyed, so a null profileId can
|
||||
* never sustain pending state even if this branch is hit.
|
||||
* - [NO_LIVE_BLE]: No live BLE snapshot at all (cache-only or empty). Not debounced —
|
||||
* the cached state is not "fresh evidence" so it must not advance the debounce counter.
|
||||
* Any active pending is cleared.
|
||||
*/
|
||||
enum class EarDetectionSource { AAP, BLE_IRK_MATCH, BLE_PROFILE_FALLBACK, BLE_ANONYMOUS, NO_LIVE_BLE }
|
||||
|
||||
data class PendingPauseDebounce(
|
||||
val profileId: String,
|
||||
val initialPodCount: Int,
|
||||
val confirmationsRemaining: Int,
|
||||
// Tolerates one count-up rebound sample before the pending is reset. Mirrors the
|
||||
// count-down debounce on the pause side: a single corrupt advert that briefly
|
||||
// shows a pod returning shouldn't kill the pending, since the next sample may
|
||||
// confirm the pods are still out.
|
||||
val resetTolerance: Int = 1,
|
||||
)
|
||||
|
||||
/** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */
|
||||
enum class PauseDebounceEvent { NONE, STARTED, ADVANCED, RESET, COMMITTED }
|
||||
|
||||
data class PauseDebounceResult(
|
||||
val decision: PlayPauseDecision,
|
||||
val pending: PendingPauseDebounce?,
|
||||
val event: PauseDebounceEvent = PauseDebounceEvent.NONE,
|
||||
)
|
||||
|
||||
internal data class PlayPauseMonitorKey(
|
||||
val profileId: String?,
|
||||
val autoPlay: Boolean,
|
||||
@@ -402,10 +679,44 @@ class PlayPause @Inject constructor(
|
||||
val isEitherPodInEar: Boolean?,
|
||||
val hasAapEarDetection: Boolean,
|
||||
val hasBleSnapshot: Boolean,
|
||||
val source: EarDetectionSource,
|
||||
// Set only for debounce-eligible sources (BLE_PROFILE_FALLBACK / BLE_ANONYMOUS) so
|
||||
// that monitor distinct filtering doesn't collapse repeated identical not-worn samples
|
||||
// and the debounce counter can advance.
|
||||
//
|
||||
// Caveat: BlePodMonitor.preferCaseContextPod can keep an existing case-context
|
||||
// snapshot in place over an incoming non-case-context snapshot, preserving the old
|
||||
// seenLastAt. This is fail-closed (delays a legitimate unauthenticated-BLE pause
|
||||
// rather than firing a false one), so accepted as a trade-off.
|
||||
val debounceFreshness: Instant?,
|
||||
)
|
||||
|
||||
internal fun PodDevice.toPlayPauseMonitorKey(): PlayPauseMonitorKey =
|
||||
PlayPauseMonitorKey(
|
||||
internal fun PodDevice.earDetectionSource(): EarDetectionSource {
|
||||
if (aap?.aapEarDetection != null) return EarDetectionSource.AAP
|
||||
if (ble == null) return EarDetectionSource.NO_LIVE_BLE
|
||||
val applePod = ble as? ApplePods
|
||||
return when {
|
||||
applePod?.meta?.isIRKMatch == true -> EarDetectionSource.BLE_IRK_MATCH
|
||||
ble.meta.profile != null -> EarDetectionSource.BLE_PROFILE_FALLBACK
|
||||
else -> EarDetectionSource.BLE_ANONYMOUS
|
||||
}
|
||||
}
|
||||
|
||||
internal fun PodDevice.toPlayPauseMonitorKey(): PlayPauseMonitorKey {
|
||||
val source = earDetectionSource()
|
||||
// Freshness applies to all unauthenticated samples (both worn and not-worn) so
|
||||
// that monitor distinct filtering doesn't collapse repeated identical samples:
|
||||
// - not-worn samples must pass through to advance the pause debounce counter
|
||||
// - worn samples must pass through to satisfy applyBleOnlyPlayConfirmation,
|
||||
// which requires a 2nd identical worn sample to confirm a staged play
|
||||
// (see commit 6825abaa "Guard BLE-only autoplay")
|
||||
// The 2-sample autoplay confirmation IS the debounce on the play side, mirroring
|
||||
// the pause debounce. isPauseDebounceResetCandidate() remains as a defense-in-depth
|
||||
// backstop for the rare case where seenLastAt doesn't advance between samples
|
||||
// (e.g. BlePodMonitor.preferCaseContextPod preserves the prior snapshot).
|
||||
val needsFreshness = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
|
||||
source == EarDetectionSource.BLE_ANONYMOUS
|
||||
return PlayPauseMonitorKey(
|
||||
profileId = profileId,
|
||||
autoPlay = reactions.autoPlay,
|
||||
autoPause = reactions.autoPause,
|
||||
@@ -418,20 +729,45 @@ class PlayPause @Inject constructor(
|
||||
isEitherPodInEar = isEitherPodInEar,
|
||||
hasAapEarDetection = aap?.aapEarDetection != null,
|
||||
hasBleSnapshot = ble != null,
|
||||
source = source,
|
||||
debounceFreshness = if (needsFreshness) ble?.seenLastAt else null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun PodDevice.isPauseDebounceResetCandidate(pending: PendingPauseDebounce?): Boolean {
|
||||
val activePending = pending?.takeIf { it.profileId == profileId } ?: return false
|
||||
val source = earDetectionSource()
|
||||
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
|
||||
source == EarDetectionSource.BLE_ANONYMOUS
|
||||
|
||||
if (!needsDebounce || !hasEarDetection) return false
|
||||
|
||||
return toEarDetectionState().podCount > activePending.initialPodCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a dual-pod [PodDevice] to [EarDetectionState] for reaction evaluation.
|
||||
* Prefers per-side values (left/right) when available, falls back to AAP aggregate
|
||||
* state (isBeingWorn/isEitherPodInEar) when per-side mapping is unknown.
|
||||
*
|
||||
* When AAP EarDetection is present, prefers AAP aggregate state — even if per-side
|
||||
* (left/right) values are non-null via BLE fallback. This avoids letting BLE per-side
|
||||
* bits drive the decision when AAP has authoritative aggregate state but
|
||||
* resolvedPrimaryPod is unknown (cmd 0x0008 not received or cleared by role swap).
|
||||
*
|
||||
* When AAP is absent, prefers per-side values (BLE) when available, falling back to
|
||||
* AAP aggregate as a last resort (which is identical to BLE aggregate in this case).
|
||||
*/
|
||||
private fun PodDevice.toEarDetectionState(): EarDetectionState {
|
||||
internal fun PodDevice.toEarDetectionState(): EarDetectionState {
|
||||
if (aap?.aapEarDetection != null) {
|
||||
return EarDetectionState.fromAapAggregate(
|
||||
isBeingWorn = isBeingWorn ?: false,
|
||||
isEitherPodInEar = isEitherPodInEar ?: false,
|
||||
)
|
||||
}
|
||||
val left = isLeftInEar
|
||||
val right = isRightInEar
|
||||
if (left != null && right != null) {
|
||||
return EarDetectionState.fromDualPod(left = left, right = right)
|
||||
}
|
||||
// Per-side unavailable (resolvedPrimaryPod is null) — use aggregate AAP state.
|
||||
return EarDetectionState.fromAapAggregate(
|
||||
isBeingWorn = isBeingWorn ?: false,
|
||||
isEitherPodInEar = isEitherPodInEar ?: false,
|
||||
@@ -440,5 +776,11 @@ class PlayPause @Inject constructor(
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Reaction", "PlayPause")
|
||||
|
||||
/**
|
||||
* Number of additional confirmations required before an unauthenticated-BLE pause
|
||||
* decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total.
|
||||
*/
|
||||
internal const val PAUSE_DEBOUNCE_SAMPLES = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package eu.darken.capod.reaction.core.sleep
|
||||
|
||||
import eu.darken.capod.common.MediaControl
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
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 eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SleepReaction @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val mediaControl: MediaControl,
|
||||
private val notifications: SleepReactionNotifications,
|
||||
private val timeSource: TimeSource,
|
||||
) {
|
||||
|
||||
private val cooldowns = ConcurrentHashMap<BluetoothAddress, Long>()
|
||||
|
||||
fun monitor(): Flow<Unit> = aapManager.sleepEvents
|
||||
.onEach { address -> handle(address) }
|
||||
.map { }
|
||||
.setupCommonEventHandlers(TAG) { "sleepReaction" }
|
||||
|
||||
private suspend fun handle(address: BluetoothAddress) {
|
||||
val now = timeSource.elapsedRealtime()
|
||||
val last = cooldowns[address]
|
||||
if (last != null && now - last < COOLDOWN_MS) {
|
||||
log(TAG) { "Sleep event from $address suppressed by cooldown" }
|
||||
return
|
||||
}
|
||||
val primary = deviceMonitor.primaryDevice().first()
|
||||
if (primary?.address != address) {
|
||||
log(TAG) { "Sleep event from $address ignored — not primary device (primary=${primary?.address})" }
|
||||
return
|
||||
}
|
||||
// Use sendPause's return value as the atomic check+act: true means we really paused
|
||||
// something, false means nothing was playing. Gating the cooldown and notification on
|
||||
// this closes the race where audio could stop between an upfront isPlaying check and
|
||||
// the key dispatch, and avoids burning the 5-minute window on no-ops.
|
||||
val paused = mediaControl.sendPause()
|
||||
if (!paused) {
|
||||
log(TAG) { "Sleep event from $address ignored — nothing was playing" }
|
||||
return
|
||||
}
|
||||
cooldowns[address] = now
|
||||
val label = primary.label ?: primary.model.label
|
||||
log(TAG, INFO) { "Sleep detected on $address ($label) — paused media, notifying" }
|
||||
notifications.show(label)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Reaction", "Sleep")
|
||||
private const val COOLDOWN_MS = 5L * 60L * 1000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package eu.darken.capod.reaction.core.sleep
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
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 eu.darken.capod.main.ui.MainActivity
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SleepReactionNotifications @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val notificationManager: NotificationManager,
|
||||
) {
|
||||
|
||||
init {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.reaction_sleep_channel_label),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun show(deviceLabel: String) {
|
||||
if (!notificationManager.areNotificationsEnabled()) {
|
||||
log(TAG, WARN) { "Notifications disabled — sleep pause will be silent" }
|
||||
return
|
||||
}
|
||||
val openPi = PendingIntent.getActivity(
|
||||
context,
|
||||
PENDING_INTENT_REQUEST_CODE,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
)
|
||||
val text = context.getString(R.string.reaction_sleep_notification_text, deviceLabel)
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.device_earbuds_generic_both)
|
||||
.setContentTitle(context.getString(R.string.reaction_sleep_notification_title))
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentIntent(openPi)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Reaction", "Sleep", "Notifications")
|
||||
private val CHANNEL_ID = "${BuildConfigWrap.APPLICATION_ID}.notification.channel.reaction.sleep"
|
||||
private const val NOTIFICATION_ID = 3
|
||||
private const val PENDING_INTENT_REQUEST_CODE = 0
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.monitor.core.ble.BleScanModeController
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
|
||||
@@ -40,6 +41,7 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val blePodMonitor: BlePodMonitor,
|
||||
private val bleScanModeController: BleScanModeController,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val debugSettings: DebugSettings,
|
||||
private val timeSource: TimeSource,
|
||||
@@ -84,39 +86,119 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
fun troubleShootBle() = launch(context = dispatcherProvider.IO) {
|
||||
log(TAG, INFO) { "troubleShootBle()" }
|
||||
|
||||
generalSettings.scannerMode.valueBlocking = ScannerMode.LOW_LATENCY
|
||||
|
||||
run {
|
||||
progress("Checking for headphones...")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
|
||||
run {
|
||||
progress("Checking for headphones...")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
if (mainDevice != null) {
|
||||
success("Headphones found, nothing to troubleshoot.")
|
||||
return@override
|
||||
} else {
|
||||
progress("Headphones not detected.\n")
|
||||
}
|
||||
}
|
||||
if (mainDevice != null) {
|
||||
success("Headphones found, nothing to troubleshoot.")
|
||||
return@launch
|
||||
} else {
|
||||
progress("Headphones not detected.\n")
|
||||
|
||||
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<BlePodSnapshot> =
|
||||
{ hardwareFilteringDisabled,
|
||||
hardwareBatchingDisabled,
|
||||
indirectCallback,
|
||||
unfiltered ->
|
||||
val sb = StringBuilder("SCAN - Settings: ")
|
||||
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
|
||||
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
|
||||
sb.append("indirectCallback=$indirectCallback, ")
|
||||
sb.append("unfiltered=$unfiltered")
|
||||
progress(sb.toString())
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
|
||||
debugSettings.showUnfiltered.valueBlocking = unfiltered
|
||||
|
||||
val start = timeSource.elapsedRealtime()
|
||||
val devices = withTimeoutOrNull(STEP_TIME) {
|
||||
blePodMonitor.devices
|
||||
.take(10)
|
||||
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
|
||||
.toList()
|
||||
.flatten()
|
||||
.distinctBy { it.address }
|
||||
} ?: emptyList()
|
||||
log(TAG) { "SCAN: BLE Devices: $devices" }
|
||||
if (devices.isNotEmpty()) {
|
||||
progress("SCAN: Received data from ${devices.size} BLE devices")
|
||||
devices
|
||||
} else {
|
||||
progress("SCAN: No data received")
|
||||
devices
|
||||
}
|
||||
}
|
||||
|
||||
run {
|
||||
progress("Checking if we can receive BLE data at all.")
|
||||
if (doScan(false, false, false, true).isNotEmpty()) return@run
|
||||
if (doScan(false, false, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, true, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, true, false, true).isNotEmpty()) return@run
|
||||
if (doScan(true, false, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, false, false, true).isNotEmpty()) return@run
|
||||
if (doScan(false, true, true, true).isNotEmpty()) return@run
|
||||
if (doScan(false, true, false, true).isNotEmpty()) return@run
|
||||
|
||||
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
|
||||
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = false
|
||||
debugSettings.showUnfiltered.valueBlocking = false
|
||||
|
||||
return@override
|
||||
}
|
||||
}
|
||||
|
||||
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<BlePodSnapshot> =
|
||||
{ hardwareFilteringDisabled,
|
||||
hardwareBatchingDisabled,
|
||||
indirectCallback,
|
||||
unfiltered ->
|
||||
val sb = StringBuilder("SCAN - Settings: ")
|
||||
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
|
||||
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
|
||||
sb.append("indirectCallback=$indirectCallback, ")
|
||||
sb.append("unfiltered=$unfiltered")
|
||||
progress(sb.toString())
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
|
||||
debugSettings.showUnfiltered.valueBlocking = unfiltered
|
||||
progress("We received at least some BLE data.\n")
|
||||
|
||||
val start = timeSource.elapsedRealtime()
|
||||
val devices = withTimeoutOrNull(STEP_TIME) {
|
||||
run {
|
||||
progress("Checking for supported headphones.")
|
||||
|
||||
if (doScan(false, false, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, false, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, true, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, true, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, false, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, false, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, true, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, true, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
|
||||
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
|
||||
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = false
|
||||
|
||||
return@override
|
||||
}
|
||||
|
||||
progress("Found some headphones that are supported by CAPod.\n")
|
||||
|
||||
run {
|
||||
progress("Checking for your headphones with new BLE settings...")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
if (mainDevice != null) {
|
||||
success("Found your headphones, new BLE settings worked :)!")
|
||||
return@override
|
||||
}
|
||||
}
|
||||
|
||||
progress("Still no headphones detected that count as yours.\n")
|
||||
|
||||
run {
|
||||
progress("Checking all closeby headphones.")
|
||||
|
||||
val otherDevices = withTimeoutOrNull(STEP_TIME) {
|
||||
val start = timeSource.elapsedRealtime()
|
||||
blePodMonitor.devices
|
||||
.take(10)
|
||||
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
|
||||
@@ -124,122 +206,40 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
.flatten()
|
||||
.distinctBy { it.address }
|
||||
} ?: emptyList()
|
||||
log(TAG) { "SCAN: BLE Devices: $devices" }
|
||||
if (devices.isNotEmpty()) {
|
||||
progress("SCAN: Received data from ${devices.size} BLE devices")
|
||||
devices
|
||||
} else {
|
||||
progress("SCAN: No data received")
|
||||
devices
|
||||
|
||||
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
|
||||
|
||||
if (otherDevices.isEmpty()) {
|
||||
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||
return@override
|
||||
}
|
||||
}
|
||||
|
||||
run {
|
||||
progress("Checking if we can receive BLE data at all.")
|
||||
if (doScan(false, false, false, true).isNotEmpty()) return@run
|
||||
if (doScan(false, false, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, true, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, true, false, true).isNotEmpty()) return@run
|
||||
if (doScan(true, false, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, false, false, true).isNotEmpty()) return@run
|
||||
if (doScan(false, true, true, true).isNotEmpty()) return@run
|
||||
if (doScan(false, true, false, true).isNotEmpty()) return@run
|
||||
progress("Headphones found nearby, but not detected as yours.\n")
|
||||
progress("Creating profile for closest headphones.")
|
||||
|
||||
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
|
||||
val candidate = otherDevices
|
||||
.filter { it !is UnknownSnapshotBle }
|
||||
.maxBy { it.signalQuality }
|
||||
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = false
|
||||
debugSettings.showUnfiltered.valueBlocking = false
|
||||
log(TAG, INFO) { "Candidate is $candidate" }
|
||||
|
||||
return@launch
|
||||
}
|
||||
profilesRepo.addProfile(
|
||||
profile = AppleDeviceProfile(
|
||||
label = context.getString(R.string.troubleshooter_title),
|
||||
model = candidate.model,
|
||||
),
|
||||
addFirst = true,
|
||||
)
|
||||
|
||||
progress("We received at least some BLE data.\n")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
|
||||
run {
|
||||
progress("Checking for supported headphones.")
|
||||
|
||||
if (doScan(false, false, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, false, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, true, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, true, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, false, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, false, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, true, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, true, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
|
||||
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
|
||||
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = false
|
||||
|
||||
return@launch
|
||||
}
|
||||
|
||||
progress("Found some headphones that are supported by CAPod.\n")
|
||||
|
||||
run {
|
||||
progress("Checking for your headphones with new BLE settings...")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
if (mainDevice != null) {
|
||||
success("Found your headphones, new BLE settings worked :)!")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
progress("Still no headphones detected that count as yours.\n")
|
||||
|
||||
run {
|
||||
progress("Checking all closeby headphones.")
|
||||
|
||||
val otherDevices = withTimeoutOrNull(STEP_TIME) {
|
||||
val start = timeSource.elapsedRealtime()
|
||||
blePodMonitor.devices
|
||||
.take(10)
|
||||
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
|
||||
.toList()
|
||||
.flatten()
|
||||
.distinctBy { it.address }
|
||||
} ?: emptyList()
|
||||
|
||||
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
|
||||
|
||||
if (otherDevices.isEmpty()) {
|
||||
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||
return@launch
|
||||
}
|
||||
|
||||
progress("Headphones found nearby, but not detected as yours.\n")
|
||||
progress("Creating profile for closest headphones.")
|
||||
|
||||
val candidate = otherDevices
|
||||
.filter { it !is UnknownSnapshotBle }
|
||||
.maxBy { it.signalQuality }
|
||||
|
||||
log(TAG, INFO) { "Candidate is $candidate" }
|
||||
|
||||
profilesRepo.addProfile(
|
||||
profile = AppleDeviceProfile(
|
||||
label = context.getString(R.string.troubleshooter_title),
|
||||
model = candidate.model,
|
||||
),
|
||||
addFirst = true,
|
||||
)
|
||||
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
|
||||
generalSettings.scannerMode.valueBlocking = ScannerMode.BALANCED
|
||||
|
||||
if (mainDevice != null) {
|
||||
success("Success! Detected your headphones.")
|
||||
} else {
|
||||
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||
if (mainDevice != null) {
|
||||
success("Success! Detected your headphones.")
|
||||
} else {
|
||||
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Wys \'n ekstra kennisgewing wanneer \'n toestel gekoppel is. Dit laat jou toe om die permanente \"Geen toestelle\" kennisgewing te versteek deur die \"Toestelstatus\" kanaal te deaktiveer.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Hou kennisgewing ná ontkoppeling</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Hou aan om die laaste bekende batteryvlakke te wys selfs nadat jou AirPods ontkoppel.</string>
|
||||
<string name="settings_scanner_mode_label">Skandeerdermodus</string>
|
||||
<string name="settings_scanner_mode_description">Moet die Bluetooth Lae Energie-dataskandeerder werkverrigting prioritiseer of energie bespaar?</string>
|
||||
<string name="settings_autopause_label">Outo-pouse</string>
|
||||
<string name="settings_autopause_description">Pouseer oudio wanneer die toestel uit jou oor verwyder word.</string>
|
||||
<string name="settings_autopplay_label">Outo-speel</string>
|
||||
@@ -152,6 +150,7 @@
|
||||
<string name="widget_config_transparency_label">Agtergrond-deursigtigheid</string>
|
||||
<string name="widget_config_show_device_label">Wys toestelnaam</string>
|
||||
<string name="widget_config_reset_label">Stel terug na standaarde</string>
|
||||
<string name="widget_config_preview_resize_hint">U kan die widget vergroot of verklein nadat u dit op u tuisskerm geplaas het.</string>
|
||||
<string name="widget_config_custom_label">Aangepas</string>
|
||||
<string name="widget_config_preset_material_you">Material You</string>
|
||||
<string name="widget_config_preset_dark">Donker</string>
|
||||
@@ -221,9 +220,6 @@
|
||||
<string name="permission_required_title">Die volgende toestemming is vereis:</string>
|
||||
<string name="permission_system_alert_window_label">Stelselwaarskuwingsvenster</string>
|
||||
<string name="permission_system_alert_window_description">Laat CAPod toe om oor ander toepassings te teken om die kenmerk Wys opspringer moontlik te maak.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Lae krag</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Gebalanseer</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Lae latensie</string>
|
||||
<string name="settings_monitor_mode_manual_label">Wanneer toepassing oop is</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Wanneer toestel gekoppel is</string>
|
||||
<string name="settings_monitor_mode_always_label">Altyd</string>
|
||||
@@ -250,6 +246,7 @@
|
||||
<string name="pods_unknown_contact_dev">Dit is \'n onbekende toestel, maar dit gebruik \'n soortgelyke boodskapformaat. Kom ons voeg ondersteuning daarvoor by, kontak my :)</string>
|
||||
<string name="pods_none_label_short">Geen toestel</string>
|
||||
<string name="pods_charging_label">Laai</string>
|
||||
<string name="pods_charging_optimized_label">Geoptimaliseer</string>
|
||||
<string name="pods_inear_label">In oor</string>
|
||||
<string name="pods_microphone_label">Mikrofoon</string>
|
||||
<string name="anc_mode_off">Af</string>
|
||||
@@ -407,11 +404,15 @@
|
||||
<string name="device_settings_info_bt_name_label">Bluetooth-toestelaanduiding</string>
|
||||
<string name="device_settings_info_model_label">Model</string>
|
||||
<string name="device_settings_info_manufacturer_label">Vervaardiger</string>
|
||||
<string name="device_settings_info_hardware_label">Hardeware</string>
|
||||
<string name="device_settings_info_serial_label">Serienommer</string>
|
||||
<string name="device_settings_info_firmware_label">Firmware</string>
|
||||
<string name="device_settings_info_firmware_pending_label">Hangende Firmware</string>
|
||||
<string name="device_settings_info_build_label">Bou</string>
|
||||
<string name="device_settings_info_left_serial_label">Linker Pod Serienommer</string>
|
||||
<string name="device_settings_info_right_serial_label">Regter Pod Serienommer</string>
|
||||
<string name="device_settings_info_left_bonded_label">Links Gekoppel</string>
|
||||
<string name="device_settings_info_right_bonded_label">Regs Gekoppel</string>
|
||||
<string name="device_settings_info_details_label">Toestelbesonderhede</string>
|
||||
<string name="device_settings_info_details_action">Wys toestelbesonderhede</string>
|
||||
<string name="device_settings_info_status_label">Status</string>
|
||||
@@ -421,8 +422,11 @@
|
||||
<string name="device_settings_not_connected_description">Hierdie toestel is naby maar nie aan hierdie foon gekoppel nie. Koppel om toegang tot instellings en beheer te verkry.</string>
|
||||
<string name="device_settings_not_connected_connect_action">Koppel</string>
|
||||
<string name="device_settings_not_connected_open_settings_action">Maak Bluetooth-instellings oop</string>
|
||||
<string name="device_settings_not_nearby_label">Toestel nie naby nie</string>
|
||||
<string name="device_settings_not_nearby_description">Instellings is slegs beskikbaar wanneer hierdie toestel naby is en aan jou foon gekoppel is.</string>
|
||||
<string name="device_settings_aap_unavailable_label">Gevorderde instellings nie beskikbaar nie</string>
|
||||
<string name="device_settings_aap_unavailable_description">Gevorderde AirPods-instellings vereis \'n Bluetooth-kenmerk wat nie op hierdie foon beskikbaar is nie. Dit kan deur \'n toekomstige Android-opdatering van jou toestelvervaardiger opgelos word.</string>
|
||||
<string name="device_settings_aap_unavailable_action">Bekyk verenigbaarheidsnaspeurder</string>
|
||||
<string name="device_settings_category_sound_label">Klank</string>
|
||||
<string name="device_settings_category_controls_label">Beheer</string>
|
||||
<string name="device_settings_nc_one_airpod_label">ANC met een pod</string>
|
||||
@@ -460,7 +464,9 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_title">Enkel druk om mikrofoon te demp</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dubbel druk om oproep te beëindig</string>
|
||||
<string name="device_settings_open_cd">Maak toestel-instellings oop</string>
|
||||
<string name="overview_card_missing_paired_device_cd">Profiel het geen gekoppelde Bluetooth-toestel nie — tik om profiel te wysig</string>
|
||||
<string name="overview_card_missing_paired_device">Hierdie profiel het geen gekoppelde Bluetooth-toestel nie.</string>
|
||||
<string name="overview_reactions_hint_title">Reaksies is per toestel</string>
|
||||
<string name="overview_reactions_hint_body">Outo-speel, outo-pouse en opspringers word nou per toestel gekonfigureer. Tik op die instellings-ikoon op \'n kaart terwyl jou kopfone verbind is.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Algemeen</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofoon</string>
|
||||
@@ -483,6 +489,9 @@
|
||||
<string name="device_settings_allow_off_description">Laat Af toe as \'n kiesbare geraasbeheermodus. Wanneer gedeaktiveer, slaan stingels Af oor tydens siklus.</string>
|
||||
<string name="device_settings_sleep_detection_label">Slaapopsporing</string>
|
||||
<string name="device_settings_sleep_detection_description">Pauses oudio outomaties wanneer jy aan die slaap raak</string>
|
||||
<string name="reaction_sleep_channel_label">Slaapopsporing</string>
|
||||
<string name="reaction_sleep_notification_title">Gepauseer deur Slaapopsporing</string>
|
||||
<string name="reaction_sleep_notification_text">%1$s het aangemeld dat jy aan die slaap geraak het, so jou musiek is gepauseer. Jy kan dit in die toestelinstellings deaktiveer.</string>
|
||||
<string name="device_settings_rename_label">Hernoem</string>
|
||||
<string name="device_settings_rename_hint">Toestelnaam</string>
|
||||
<string name="device_settings_rename_confirm">Hernoem</string>
|
||||
@@ -492,10 +501,14 @@
|
||||
<string name="device_settings_rename_system_unavailable_bt_settings_action">Bluetooth-instellings</string>
|
||||
<string name="device_settings_send_failed">Kon nie instelling toepas nie: %1$s</string>
|
||||
<string name="device_settings_anc_off_rejected_message">Af-modus is nie op hierdie toestel geaktiveer nie. Aktiveer \"Laat Af-modus toe\" onder Geraasbeheersing.</string>
|
||||
<string name="device_settings_category_battery_label">Batterij</string>
|
||||
<string name="device_settings_charge_cap_label">Geoptimaliseerde Laailimiet</string>
|
||||
<string name="device_settings_charge_cap_description">Leer jou roetine en laat laai rondom 80%% pouseer om batteryleeftyd te verleng, en vul die dopjes aan voordat jy hulle waarskynlik sal gebruik.</string>
|
||||
<string name="device_settings_charge_cap_rejected_message">Geoptimaliseerde Laailimiet kon nie verander word nie. Dit is \'n eksperimentele funksie — meld asseblief as dit aanhou misluk.</string>
|
||||
<string name="device_settings_category_connections_label">Gekoppelde toestelle</string>
|
||||
<string name="device_settings_connected_devices_description">Ander toestelle wat tans aan hierdie AirPods gekoppel is</string>
|
||||
<string name="device_settings_connected_device_label">Toestel %d</string>
|
||||
<string name="device_settings_connected_device_call">In \’n oproep</string>
|
||||
<string name="device_settings_connected_device_call">In ’n oproep</string>
|
||||
<string name="device_settings_connected_device_media">Speel media</string>
|
||||
<string name="device_settings_noise_control_label">Geraasbeheer</string>
|
||||
<string name="device_settings_eq_label">Egaliseerder</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">አንድ መሣሪያ ሲገናኝ ተጨማሪ ማሳወቂያ ያሳያል። ይህ የ \"የመሣሪያ ሁኔታ\" ቻናልን በማሰናከል ቋሚውን \"ምንም መሣሪያዎች የሉም\" የሚለውን ማሳወቂያ እንዲደብቁ ያስችልዎታል።</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">ከተነተቀ በኋላ ማሳወቂያውን ይያዙ</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">AirPods ከተነተቁ በኋላም የመጨረሻውን የታወቀ የባትሪ ደረጃ ማሳየቱን ይቀጥሉ</string>
|
||||
<string name="settings_scanner_mode_label">የቃኚ ሁነታ</string>
|
||||
<string name="settings_scanner_mode_description">የብሉቱዝ ዝቅተኛ ኃይል ዳታ ቃኚው አፈጻጸምን ቅድሚያ መስጠት አለበት ወይስ ኃይል መቆጠብ አለበት?</string>
|
||||
<string name="settings_autopause_label">ራስ-ሰር ለአፍታ ማቆም</string>
|
||||
<string name="settings_autopause_description">መሳሪያውን ከጆሮዎ ሲያወጡ ድምጽን ለአፍታ ያቁሙ።</string>
|
||||
<string name="settings_autopplay_label">ራስ-ሰር አጫውት</string>
|
||||
@@ -152,6 +150,7 @@
|
||||
<string name="widget_config_transparency_label">የዳራ ግልጽነት</string>
|
||||
<string name="widget_config_show_device_label">የመሳሪያ ስም አሳይ</string>
|
||||
<string name="widget_config_reset_label">ወደ ነባሪ መመለስ</string>
|
||||
<string name="widget_config_preview_resize_hint">ዊጄቱን በዋናው ስክሪን ላይ ካስቀመጡት በኋላ መጠኑን መቀየር ይችላሉ።</string>
|
||||
<string name="widget_config_custom_label">ብጁ</string>
|
||||
<string name="widget_config_preset_material_you">Material You</string>
|
||||
<string name="widget_config_preset_dark">ጨለማ</string>
|
||||
@@ -221,9 +220,6 @@
|
||||
<string name="permission_required_title">የሚከተለው ፈቃድ ያስፈልጋል:</string>
|
||||
<string name="permission_system_alert_window_label">የስርዓት ማስጠንቀቂያ መስኮት</string>
|
||||
<string name="permission_system_alert_window_description">\"ብቅ-ባይ አሳይ\" ባህሪውን ለማስቻል CAPod በሌሎች መተግበሪያዎች ላይ እንዲሳል ፍቀድ።</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">ዝቅተኛ ኃይል</string>
|
||||
<string name="settings_scanner_mode_balanced_label">ሚዛናዊ</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">ዝቅተኛ መዘግየት</string>
|
||||
<string name="settings_monitor_mode_manual_label">መተግበሪያው ሲከፈት</string>
|
||||
<string name="settings_monitor_mode_automatic_label">መሣሪያ ሲገናኝ</string>
|
||||
<string name="settings_monitor_mode_always_label">ሁልጊዜ</string>
|
||||
@@ -250,6 +246,7 @@
|
||||
<string name="pods_unknown_contact_dev">ይህ ያልታወቀ መሣሪያ ነው ግን ተመሳሳይ የመልዕክት ቅርጸት ይጠቀማል። ድጋፍ እንጨምርለት፣ ያግኙኝ :)</string>
|
||||
<string name="pods_none_label_short">ምንም መሣሪያ የለም</string>
|
||||
<string name="pods_charging_label">በመሙላት ላይ</string>
|
||||
<string name="pods_charging_optimized_label">ተመቻችቷል</string>
|
||||
<string name="pods_inear_label">በጆሮ ውስጥ</string>
|
||||
<string name="pods_microphone_label">ማይክሮፎን</string>
|
||||
<string name="anc_mode_off">ጠፍቷል</string>
|
||||
@@ -407,11 +404,15 @@
|
||||
<string name="device_settings_info_bt_name_label">የብሉቱዝ መሳሪያ ስያሜ</string>
|
||||
<string name="device_settings_info_model_label">ሞዴል</string>
|
||||
<string name="device_settings_info_manufacturer_label">አምራች</string>
|
||||
<string name="device_settings_info_hardware_label">ሃርድዌር</string>
|
||||
<string name="device_settings_info_serial_label">ተከታታይ ቁጥር</string>
|
||||
<string name="device_settings_info_firmware_label">Firmware</string>
|
||||
<string name="device_settings_info_firmware_pending_label">ወደ ላይ ለሚጫን ፈርምዌር</string>
|
||||
<string name="device_settings_info_build_label">ግንባታ</string>
|
||||
<string name="device_settings_info_left_serial_label">የግራ ፖድ ተከታታይ ቅጥር</string>
|
||||
<string name="device_settings_info_right_serial_label">የቀኝ ፖድ ተከታታይ ቅጥር</string>
|
||||
<string name="device_settings_info_left_bonded_label">ግራ ተያይዟል</string>
|
||||
<string name="device_settings_info_right_bonded_label">ቀኝ ተያይዟል</string>
|
||||
<string name="device_settings_info_details_label">የመሳሪያ ዝርዝሮች</string>
|
||||
<string name="device_settings_info_details_action">የመሳሪያ ዝርዝሮችን አሳይ</string>
|
||||
<string name="device_settings_info_status_label">ሁኔታ</string>
|
||||
@@ -421,8 +422,11 @@
|
||||
<string name="device_settings_not_connected_description">ይህ መሣሪያ አቅራቢያ አለ ነገር ግን ከዚህ ስልክ ጋር አልተገናኘም። ቅንብሮችን እና መቆጣጠሪያዎችን ለማግኘት ያገናኙ።</string>
|
||||
<string name="device_settings_not_connected_connect_action">ያገናኙ</string>
|
||||
<string name="device_settings_not_connected_open_settings_action">የብሉቱዝ ቅንብሮችን ይክፈቱ</string>
|
||||
<string name="device_settings_not_nearby_label">መሣሪያው ቅርብ አይደለም</string>
|
||||
<string name="device_settings_not_nearby_description">ቅንብሮቹ የሚገኙት ይህ መሣሪያ ቅርብ ሆኖ ከስልክዎ ጋር ሲገናኝ ብቻ ነው።</string>
|
||||
<string name="device_settings_aap_unavailable_label">የላቁ ቅንብሮች አይገኙም</string>
|
||||
<string name="device_settings_aap_unavailable_description">የላቀ AirPods ቅንብሮች በዚህ ስለክ ላይ የማይገኝ የብሉቱዝ ባህሪ ይፈልጋሉ። ይህ ከወደፊቱ ከመሳሪያ አምራችዎ የሚመጣ የAndroid ዝማኔ ሊፈታ ይችላል።</string>
|
||||
<string name="device_settings_aap_unavailable_action">የተኳሃኝነት ተከታይ ይመልከቱ</string>
|
||||
<string name="device_settings_category_sound_label">ድምፅ</string>
|
||||
<string name="device_settings_category_controls_label">መቀጣጣሪያዎች</string>
|
||||
<string name="device_settings_nc_one_airpod_label">ANC በአንድ ፖድ</string>
|
||||
@@ -460,7 +464,9 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_title">ማይክሮፎኑን ለማጥፈት አንድ ጊዜ ጀን</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">ጥሪን ለመጨረስ ሁለት ጊዜ ጀን</string>
|
||||
<string name="device_settings_open_cd">የመሣሪያ ቅንብሮችን ክፈት</string>
|
||||
<string name="overview_card_missing_paired_device_cd">ፕሮፋይሉ ምንም ተጣምሮ የ Bluetooth መሳሪያ የለም — ፕሮፋይሉን ለማስተካከል መታ ያድርጉ</string>
|
||||
<string name="overview_card_missing_paired_device">ይህ መገለጫ የጋራ ብሉቱዝ መሳሪያ የለውም።</string>
|
||||
<string name="overview_reactions_hint_title">ምላሾች በእያንዳንዱ መሳሪያ ናቸው</string>
|
||||
<string name="overview_reactions_hint_body">የራስ-ማጫወት፣ የራስ-ማቆም እና ብቅ-ባይ መስኮቶች አሁን በእያንዳንዱ መሳሪያ ይዋቀራሉ። ጆሮ ማዳመጫዎ ሲገናኙ ካርዱ ላይ ያለውን የቅንብሮች አዶ መታ ያድርጉ።</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">አጠቃላይ</string>
|
||||
<string name="device_settings_microphone_mode_label">ማይክሮፎን</string>
|
||||
@@ -483,6 +489,9 @@
|
||||
<string name="device_settings_allow_off_description">ጠፍን እንደ ሊመረጥ የሚችል የጫጫታ ቁጥጥር ሁኔታ ፍቀድ። ሲሰናከል፣ ቀንዶቹ ዑደት ሲያደርጉ ጠፍን ይዝለሉ።</string>
|
||||
<string name="device_settings_sleep_detection_label">እንቅልፍ ማወቃያ</string>
|
||||
<string name="device_settings_sleep_detection_description">ሲተኝ ድምጹን ራስ-ሰር አቁም</string>
|
||||
<string name="reaction_sleep_channel_label">የእንቅልፍ ማወቂያ</string>
|
||||
<string name="reaction_sleep_notification_title">በእንቅልፍ ማወቂያ ምክንያት ታግዷል</string>
|
||||
<string name="reaction_sleep_notification_text">%1$s እርስዎ ተኝተዋል ብሎ ዘገበ፣ ስለዚህ ሙዚቃዎ ተቋርጧል። ይህን በመሣሪያ ቅንብሮች ውስጥ ማሰናከል ይችላሉ።</string>
|
||||
<string name="device_settings_rename_label">ስም ቀይር</string>
|
||||
<string name="device_settings_rename_hint">የመሣሪያ ስም</string>
|
||||
<string name="device_settings_rename_confirm">ስም ቀይር</string>
|
||||
@@ -492,6 +501,10 @@
|
||||
<string name="device_settings_rename_system_unavailable_bt_settings_action">የ Bluetooth ቅንብሮች</string>
|
||||
<string name="device_settings_send_failed">ቅንብሩን መተግበር አልተቻለም: %1$s</string>
|
||||
<string name="device_settings_anc_off_rejected_message">ጠፍ ሁኔታ በዚህ መሳሪያ ላይ አልነቃም። \"ጠፍ ሁኔታን ፍቀድ\" በጫጫታ ቁጥጥር ስር አንቃ።</string>
|
||||
<string name="device_settings_category_battery_label">ባትሪ</string>
|
||||
<string name="device_settings_charge_cap_label">ተመቻችቶ የሚቆጠር የኃይል ወሰን</string>
|
||||
<string name="device_settings_charge_cap_description">የእርስዎን ልምድ ተምሮ የባትሪ ሕይወት ለማሳደግ ወደ 80%% ሲደርስ ኤሌክትሪኩን ያቆማል፣ ሊጠቀሟቸው ከመሆናቸው በፊት ድብዳቤ ኮዳቹን ሙሉ ይሞላቸዋል።</string>
|
||||
<string name="device_settings_charge_cap_rejected_message">ተመቻችቶ የሚቆጠር የኃይል ወሰን ሊቀየር አልቻለም። ይህ የሙከራ ባህሪ ነው — አሁንም ቢሆን ካልሰራ እባክዎ ሪፖርት ያድርጉ።</string>
|
||||
<string name="device_settings_category_connections_label">የተገናኙ መሣሪያዎች</string>
|
||||
<string name="device_settings_connected_devices_description">አሁን ለእነዚህ AirPods የተገናኙ ሌሎች መሣሪያዎች</string>
|
||||
<string name="device_settings_connected_device_label">መሣሪያ %d</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">يعرض إشعارًا إضافيًا عند توصيل جهاز. يتيح لك هذا إخفاء الإشعار الدائم \"لا توجد أجهزة\" عن طريق تعطيل قناة \"حالة الجهاز\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">الاحتفاظ بالإشعار بعد قطع الاتصال</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">الاستمرار في عرض مستويات البطارية المعروفة الأخيرة حتى بعد قطع اتصال AirPods</string>
|
||||
<string name="settings_scanner_mode_label">وضع الماسح الضوئي</string>
|
||||
<string name="settings_scanner_mode_description">هل يجب أن يعطي ماسح بيانات البلوتوث منخفض الطاقة الأولوية للأداء أم يحافظ على الطاقة؟</string>
|
||||
<string name="settings_autopause_label">إيقاف مؤقت تلقائي</string>
|
||||
<string name="settings_autopause_description">إيقاف الصوت مؤقتًا عند إزالة الجهاز من أذنك.</string>
|
||||
<string name="settings_autopplay_label">تشغيل تلقائي</string>
|
||||
@@ -152,6 +150,7 @@
|
||||
<string name="widget_config_transparency_label">شفافية الخلفية</string>
|
||||
<string name="widget_config_show_device_label">إظهار اسم الجهاز</string>
|
||||
<string name="widget_config_reset_label">إعادة تعيين إلى الإعدادات الافتراضية</string>
|
||||
<string name="widget_config_preview_resize_hint">يمكنك تغيير حجم الأداة بعد وضعها على شاشتك الرئيسية.</string>
|
||||
<string name="widget_config_custom_label">مخصص</string>
|
||||
<string name="widget_config_preset_material_you">Material You</string>
|
||||
<string name="widget_config_preset_dark">مظلم</string>
|
||||
@@ -229,9 +228,6 @@
|
||||
<string name="permission_required_title">الإذن التالي مطلوب:</string>
|
||||
<string name="permission_system_alert_window_label">نافذة تنبيه النظام</string>
|
||||
<string name="permission_system_alert_window_description">السماح لـ CAPod بالرسم فوق التطبيقات الأخرى لتمكين ميزة \"إظهار النافذة المنبثقة\".</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">طاقة منخفضة</string>
|
||||
<string name="settings_scanner_mode_balanced_label">متوازن</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">تأخير منخفض</string>
|
||||
<string name="settings_monitor_mode_manual_label">عند فتح التطبيق</string>
|
||||
<string name="settings_monitor_mode_automatic_label">عند اتصال الجهاز</string>
|
||||
<string name="settings_monitor_mode_always_label">دائمًا</string>
|
||||
@@ -258,6 +254,7 @@
|
||||
<string name="pods_unknown_contact_dev">هذا جهاز غير معروف، لكنه يستخدم تنسيق رسائل مشابهًا. دعونا نضيف دعمًا له، تواصل معي :)</string>
|
||||
<string name="pods_none_label_short">لا يوجد جهاز</string>
|
||||
<string name="pods_charging_label">جارٍ الشحن</string>
|
||||
<string name="pods_charging_optimized_label">محسَّن</string>
|
||||
<string name="pods_inear_label">في الأذن</string>
|
||||
<string name="pods_microphone_label">الميكروفون</string>
|
||||
<string name="anc_mode_off">إيقاف</string>
|
||||
@@ -427,11 +424,15 @@
|
||||
<string name="device_settings_info_bt_name_label">تسمية جهاز البلوتوث</string>
|
||||
<string name="device_settings_info_model_label">الطراز</string>
|
||||
<string name="device_settings_info_manufacturer_label">الشركة المصنّعة</string>
|
||||
<string name="device_settings_info_hardware_label">الجهاز</string>
|
||||
<string name="device_settings_info_serial_label">الرقم التسلسلي</string>
|
||||
<string name="device_settings_info_firmware_label">البرنامج الثابت</string>
|
||||
<string name="device_settings_info_firmware_pending_label">البرنامج الثابت المعلق</string>
|
||||
<string name="device_settings_info_build_label">الإصدار</string>
|
||||
<string name="device_settings_info_left_serial_label">الرقم التسلسلي للسماعة اليسرى</string>
|
||||
<string name="device_settings_info_right_serial_label">الرقم التسلسلي للسماعة اليمنى</string>
|
||||
<string name="device_settings_info_left_bonded_label">اليسار مقترن</string>
|
||||
<string name="device_settings_info_right_bonded_label">اليمين مقترن</string>
|
||||
<string name="device_settings_info_details_label">تفاصيل الجهاز</string>
|
||||
<string name="device_settings_info_details_action">عرض تفاصيل الجهاز</string>
|
||||
<string name="device_settings_info_status_label">الحالة</string>
|
||||
@@ -441,8 +442,11 @@
|
||||
<string name="device_settings_not_connected_description">هذا الجهاز قريب لكنه غير متصل بهذا الهاتف. اتصل للوصول إلى الإعدادات والعناصر.</string>
|
||||
<string name="device_settings_not_connected_connect_action">اتصال</string>
|
||||
<string name="device_settings_not_connected_open_settings_action">فتح إعدادات البلوتوث</string>
|
||||
<string name="device_settings_not_nearby_label">الجهاز غير قريب</string>
|
||||
<string name="device_settings_not_nearby_description">الإعدادات متاحة فقط عندما يكون هذا الجهاز قريبًا ومتصلًا بهاتفك.</string>
|
||||
<string name="device_settings_aap_unavailable_label">الإعدادات المتقدمة غير متاحة</string>
|
||||
<string name="device_settings_aap_unavailable_description">تتطلب إعدادات AirPods المتقدمة ميزة بلوتوث غير متاحة على هذا الهاتف. قد يتم حل هذا بتحديث Android مستقبلي من الشركة المصنّعة لجهازك.</string>
|
||||
<string name="device_settings_aap_unavailable_action">عرض متتبع التوافق</string>
|
||||
<string name="device_settings_category_sound_label">الصوت</string>
|
||||
<string name="device_settings_category_controls_label">عناصر التحكم</string>
|
||||
<string name="device_settings_nc_one_airpod_label">إلغاء ضوضاء نشط مع سماعة واحدة</string>
|
||||
@@ -480,7 +484,9 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_title">ضغطة واحدة لكتم صوت الميكروفون</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">ضغطة مزدوجة لإنهاء المكالمة</string>
|
||||
<string name="device_settings_open_cd">فتح إعدادات الجهاز</string>
|
||||
<string name="overview_card_missing_paired_device_cd">الملف الشخصي لا يحتوي على جهاز بلوتوث مقترن — انقر للتعديل</string>
|
||||
<string name="overview_card_missing_paired_device">هذا الملف الشخصي لا يحتوي على جهاز بلوتوث مقترن.</string>
|
||||
<string name="overview_reactions_hint_title">ردود الفعل خاصة بكل جهاز</string>
|
||||
<string name="overview_reactions_hint_body">يتم الآن تهيئة التشغيل التلقائي والإيقاف التلقائي والنوافذ المنبثقة لكل جهاز على حدة. انقر على أيقونة الإعدادات على البطاقة أثناء توصيل سماعاتك.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">عام</string>
|
||||
<string name="device_settings_microphone_mode_label">ميكروفون</string>
|
||||
@@ -503,6 +509,9 @@
|
||||
<string name="device_settings_allow_off_description">السماح بوضع إيقاف كوضع تحكم قابل للتحديد في الضوضاء. عند التعطيل، تتجاوز الذراعان وضع إيقاف أثناء الدوران.</string>
|
||||
<string name="device_settings_sleep_detection_label">اكتشاف النوم</string>
|
||||
<string name="device_settings_sleep_detection_description">إيقاف الصوت تلقائيًا عند النوم</string>
|
||||
<string name="reaction_sleep_channel_label">اكتشاف النوم</string>
|
||||
<string name="reaction_sleep_notification_title">تم الإيقاف المؤقت بواسطة اكتشاف النوم</string>
|
||||
<string name="reaction_sleep_notification_text">%1$s أفاد بأنك نمت، لذا تم إيقاف موسيقاك مؤقتًا. يمكنك تعطيل هذا في إعدادات الجهاز.</string>
|
||||
<string name="device_settings_rename_label">إعادة تسمية</string>
|
||||
<string name="device_settings_rename_hint">اسم الجهاز</string>
|
||||
<string name="device_settings_rename_confirm">إعادة تسمية</string>
|
||||
@@ -512,6 +521,10 @@
|
||||
<string name="device_settings_rename_system_unavailable_bt_settings_action">إعدادات البلوتوث</string>
|
||||
<string name="device_settings_send_failed">تعذّر تطبيق الإعداد: %1$s</string>
|
||||
<string name="device_settings_anc_off_rejected_message">وضع إيقاف غير مفعّل على هذا الجهاز. فعّل \"السماح بوضع إيقاف\" ضمن التحكم في الضوضاء.</string>
|
||||
<string name="device_settings_category_battery_label">البطارية</string>
|
||||
<string name="device_settings_charge_cap_label">حد الشحن المحسَّن</string>
|
||||
<string name="device_settings_charge_cap_description">يتعلم روتينك ويوقف الشحن مؤقتًا عند حوالي 80%% لإطالة عمر البطارية، مع شحن السماعات بالكامل قبل استخدامك المتوقع لها.</string>
|
||||
<string name="device_settings_charge_cap_rejected_message">تعذّر تغيير حد الشحن المحسَّن. هذه ميزة تجريبية — يرجى الإبلاغ إذا استمر الفشل.</string>
|
||||
<string name="device_settings_category_connections_label">الأجهزة المتصلة</string>
|
||||
<string name="device_settings_connected_devices_description">الأجهزة الأخرى المتصلة حاليًا بهذه AirPods</string>
|
||||
<string name="device_settings_connected_device_label">الجهاز %d</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Cihaz qoşulduqda əlavə bildiriş göstərir. Bu, \"Cihaz statusu\" kanalını deaktiv etməklə daimi \"Cihaz yoxdur\" bildirişini gizlətməyə imkan verir.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Ayrıldıqdan sonra bildirişi saxla</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">AirPods ayrıldıqdan sonra belə sonuncu bilinən batareya səviyyələrini göstərməyə davam et</string>
|
||||
<string name="settings_scanner_mode_label">Skaner rejimi</string>
|
||||
<string name="settings_scanner_mode_description">Bluetooth Aşağı Enerji məlumat skaneri performansa üstünlük verməli, yoxsa enerjiyə qənaət etməlidir?</string>
|
||||
<string name="settings_autopause_label">Avtomatik fasilə</string>
|
||||
<string name="settings_autopause_description">Cihazı qulağınızdan çıxardıqda səsi dayandırın.</string>
|
||||
<string name="settings_autopplay_label">Avtomatik oxutma</string>
|
||||
@@ -152,6 +150,7 @@
|
||||
<string name="widget_config_transparency_label">Fon şəffaflığı</string>
|
||||
<string name="widget_config_show_device_label">Cihaz adını göstər</string>
|
||||
<string name="widget_config_reset_label">Varsayılan ayarlara sıfırla</string>
|
||||
<string name="widget_config_preview_resize_hint">Vidceti ana ekranınıza yerləşdirdikdən sonra ölçüsünü dəyişə bilərsiniz.</string>
|
||||
<string name="widget_config_custom_label">Fərdi</string>
|
||||
<string name="widget_config_preset_material_you">Material You</string>
|
||||
<string name="widget_config_preset_dark">Tünd</string>
|
||||
@@ -221,9 +220,6 @@
|
||||
<string name="permission_required_title">Aşağıdakı icazə tələb olunur:</string>
|
||||
<string name="permission_system_alert_window_label">Sistem xəbərdarlıq pəncərəsi</string>
|
||||
<string name="permission_system_alert_window_description">\"Pop-up göstər\" xüsusiyyətini mümkün etmək üçün CAPod-a digər tətbiqlərin üzərində çəkməyə icazə verin.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Aşağı güc</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Balanslaşdırılmış</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Aşağı gecikmə</string>
|
||||
<string name="settings_monitor_mode_manual_label">Tətbiq açıq olduqda</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Cihaz qoşulduqda</string>
|
||||
<string name="settings_monitor_mode_always_label">Həmişə</string>
|
||||
@@ -233,7 +229,7 @@
|
||||
<string name="pods_dual_left_label">Sol qulaqlıq</string>
|
||||
<string name="pods_dual_right_label">Sağ qulaqlıq</string>
|
||||
<string name="pods_dual_left_short_label">S</string>
|
||||
<string name="pods_dual_right_short_label">S</string>
|
||||
<string name="pods_dual_right_short_label">SĚ</string>
|
||||
<string name="pods_case_label">Qutu</string>
|
||||
<string name="battery_unavailable_label">Batareya məlumatı yoxdur</string>
|
||||
<string name="pods_case_status_open_label">Açıq</string>
|
||||
@@ -250,6 +246,7 @@
|
||||
<string name="pods_unknown_contact_dev">Bu naməlum bir cihazdır, lakin oxşar mesaj formatından istifadə edir. Onun üçün dəstək əlavə edək, mənimlə əlaqə saxlayın :)</string>
|
||||
<string name="pods_none_label_short">Cihaz yoxdur</string>
|
||||
<string name="pods_charging_label">Şarj olunur</string>
|
||||
<string name="pods_charging_optimized_label">Optimallaşdırılmış</string>
|
||||
<string name="pods_inear_label">Qulaqda</string>
|
||||
<string name="pods_microphone_label">Mikrofon</string>
|
||||
<string name="anc_mode_off">Söndür</string>
|
||||
@@ -407,11 +404,15 @@
|
||||
<string name="device_settings_info_bt_name_label">Bluetooth Cihaz Etiketə</string>
|
||||
<string name="device_settings_info_model_label">Model</string>
|
||||
<string name="device_settings_info_manufacturer_label">İstehsalcı</string>
|
||||
<string name="device_settings_info_hardware_label">Aparat</string>
|
||||
<string name="device_settings_info_serial_label">Seriya Nömrəsi</string>
|
||||
<string name="device_settings_info_firmware_label">Proqram təminı</string>
|
||||
<string name="device_settings_info_firmware_pending_label">Gözləmədə olan proqram təminatı</string>
|
||||
<string name="device_settings_info_build_label">Yazılım versiyası</string>
|
||||
<string name="device_settings_info_left_serial_label">Sol Qulaqcıq Seriya Nömrəsi</string>
|
||||
<string name="device_settings_info_right_serial_label">Sağ Qulaqcıq Seriya Nömrəsi</string>
|
||||
<string name="device_settings_info_left_bonded_label">Sol cütləşdirilmiş</string>
|
||||
<string name="device_settings_info_right_bonded_label">Sağ cütləşdirilmiş</string>
|
||||
<string name="device_settings_info_details_label">Cihaz Ǝtraflı Məlumatları</string>
|
||||
<string name="device_settings_info_details_action">Cihaz təfsilatlarını göstər</string>
|
||||
<string name="device_settings_info_status_label">Vəziyyət</string>
|
||||
@@ -421,8 +422,11 @@
|
||||
<string name="device_settings_not_connected_description">Bu cihaz yaxında yerləşir, lakin bu telefona bağlı deyil. Parametrlərə və idarəetməyə daxil olmaq üçün qoşulun.</string>
|
||||
<string name="device_settings_not_connected_connect_action">Qoşul</string>
|
||||
<string name="device_settings_not_connected_open_settings_action">Bluetooth Parametrlərini Aç</string>
|
||||
<string name="device_settings_not_nearby_label">Cihaz yaxında deyil</string>
|
||||
<string name="device_settings_not_nearby_description">Parametrlər yalnız bu cihaz yaxında olduqda və telefonunuza qoşulduqda mövcuddur.</string>
|
||||
<string name="device_settings_aap_unavailable_label">Ətraflı parametrlər əlçatmazdır</string>
|
||||
<string name="device_settings_aap_unavailable_description">Qabaqcıl AirPods parametrləri bu telefondan əldati olunan Bluetooth funksiyasını tələb edir. Bu problem cihaz istehsalcınızdan gələcək Android yenilməsi ilə həll oluna bilər.</string>
|
||||
<string name="device_settings_aap_unavailable_action">Uyğunluq izləyicisinə baxın</string>
|
||||
<string name="device_settings_category_sound_label">Səs</string>
|
||||
<string name="device_settings_category_controls_label">İdarəetmələr</string>
|
||||
<string name="device_settings_nc_one_airpod_label">Tək qulaqcıqla ANC</string>
|
||||
@@ -460,7 +464,9 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_title">Mikrofonu susdurmağ üçün bir dəfə basın</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Zəngi bitirmək üçün iki dəfə basın</string>
|
||||
<string name="device_settings_open_cd">Cihaz parametrlərini açın</string>
|
||||
<string name="overview_card_missing_paired_device_cd">Profilin cütləşdirilmiş Bluetooth cihazı yoxdur — profili redaktə etmək üçün toxunun</string>
|
||||
<string name="overview_card_missing_paired_device">Bu profilin cütləşdirilmiş Bluetooth cihazı yoxdur.</string>
|
||||
<string name="overview_reactions_hint_title">Reaksiyalar hər cihaz üçün ayrıdır</string>
|
||||
<string name="overview_reactions_hint_body">Avtomatik oxutma, avtomatik dayandırma və açılan pəncərələr artıq hər cihaz üçün ayrıca konfiqurasiya edilir. Qulaqlıqlarınız qoşulu olarkən kartdakı parametrlər ikonasına toxunun.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Ümumi</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
||||
@@ -483,6 +489,9 @@
|
||||
<string name="device_settings_allow_off_description">Söndürmə rejiminin səs-küy nəzarətində seçilə bilməsinə icazə ver. Deaktiv edildçə, gövə dövr kəsindi Söndürməni atlayar.</string>
|
||||
<string name="device_settings_sleep_detection_label">Yuxu Aşkarlama</string>
|
||||
<string name="device_settings_sleep_detection_description">Yuxuya getdikdə audionu avtomatik dayandırın</string>
|
||||
<string name="reaction_sleep_channel_label">Yuxu Aşkarlaması</string>
|
||||
<string name="reaction_sleep_notification_title">Yuxu Aşkarlaması ilə dayandırıldı</string>
|
||||
<string name="reaction_sleep_notification_text">%1$s sizin yuxuya getdiyinizi bildirdi, buna görə musiqiniz dayandırıldı. Bunu cihaz parametrlərindən söndürə bilərsiniz.</string>
|
||||
<string name="device_settings_rename_label">Adı dəyişdirin</string>
|
||||
<string name="device_settings_rename_hint">Cihaz adı</string>
|
||||
<string name="device_settings_rename_confirm">Adı dəyişdirin</string>
|
||||
@@ -492,6 +501,10 @@
|
||||
<string name="device_settings_rename_system_unavailable_bt_settings_action">Bluetooth Parametrləri</string>
|
||||
<string name="device_settings_send_failed">Parametr tətbiq edilə bilmədi: %1$s</string>
|
||||
<string name="device_settings_anc_off_rejected_message">Söndürmə rejimi bu cihazda aktiv deyil. Səs-Küy Nəzarəti altında \"Söndürmə rejiminə icazə ver\" seçimini aktiv edin.</string>
|
||||
<string name="device_settings_category_battery_label">Batareya</string>
|
||||
<string name="device_settings_charge_cap_label">Optimallaşdırılmış Şarj Həddi</string>
|
||||
<string name="device_settings_charge_cap_description">Batareya ömrünü uzatmaq üçün rutininizi öyrənir və şarjı təxminən 80%%-də dayandırır, qulluqlardan istifadə etməzdən əvvəl onları şarj edir.</string>
|
||||
<string name="device_settings_charge_cap_rejected_message">Optimallaşdırılmış Şarj Həddini dəyişdirmək mümkün olmadı. Bu eksperimental bir xüsusiyyətdir — davam edərsə xahiş edirik bildirin.</string>
|
||||
<string name="device_settings_category_connections_label">Bağlı Cihazlar</string>
|
||||
<string name="device_settings_connected_devices_description">Bu AirPodlara hal-hazırda bağlı olan digər cihazlar</string>
|
||||
<string name="device_settings_connected_device_label">Cihaz %d</string>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user