mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 11:16:12 -04:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d30844fa8 | ||
|
|
af1b1b4249 | ||
|
|
2cce9ae56c | ||
|
|
4bab7c61a9 | ||
|
|
81e2bc1dba | ||
|
|
df2783d8a1 | ||
|
|
20a6953b54 | ||
|
|
fdd4f69247 | ||
|
|
5ee777b196 | ||
|
|
dd827af3cd | ||
|
|
948205667b | ||
|
|
dbbfa75429 | ||
|
|
78790f73df | ||
|
|
2950d8b3f9 | ||
|
|
4d3a9bc165 | ||
|
|
7d57845502 | ||
|
|
83ef111599 | ||
|
|
0e6848d886 | ||
|
|
1eeeb63a76 | ||
|
|
a4ba302223 | ||
|
|
8c9094d85a | ||
|
|
4529b107dd | ||
|
|
17663bc759 | ||
|
|
278257998b | ||
|
|
9d41979499 | ||
|
|
985b73754e | ||
|
|
e4e4190523 | ||
|
|
ebfb034fdd | ||
|
|
8a81d354e6 | ||
|
|
ac0c4693dc | ||
|
|
4fd7a41c53 | ||
|
|
cc1caf47f0 | ||
|
|
8b9f953818 | ||
|
|
d541de8c38 | ||
|
|
2821da434d | ||
|
|
07c72dc86c | ||
|
|
7d93e772b4 | ||
|
|
82dc88faac | ||
|
|
06b41ec8eb | ||
|
|
acc6e92b2d | ||
|
|
39922df792 | ||
|
|
5a8c4b94ef | ||
|
|
0ffa87deea | ||
|
|
fc98db09a2 | ||
|
|
e1c0a702dd |
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 760 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 761 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 605 KiB |
@@ -60,7 +60,7 @@ bats tools/release/bump.bats
|
|||||||
|
|
||||||
Required org secrets (set on the d4rken-org organization, accessible to `capod`):
|
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_CLIENT_ID` — Client ID of the `d4rken-org-releaser` GitHub App (visible on the App's settings page, format `Iv1.<hex>` or similar)
|
||||||
- `RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines)
|
- `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 App is installed on this repo and added as a bypass actor to:
|
||||||
|
|||||||
@@ -25,16 +25,23 @@ ScreenshotContent.kt (mock data + composables)
|
|||||||
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
|
| `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 |
|
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
|
||||||
|
|
||||||
|
## Commit policy
|
||||||
|
|
||||||
|
Only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) have `phoneScreenshots/*.png` checked into the repo. Non-smoke locales are excluded by `.gitignore`. This mirrors permission-pilot and keeps repo size small (~7 MB vs ~67 MB for the full 68 locales).
|
||||||
|
|
||||||
|
Play Store's `supply` only uploads what's present in `fastlane/metadata/android/<locale>/images/phoneScreenshots/`. For locales not in the upload, Play Store retains whatever was last pushed. So full localization on Play Store is maintained by an **occasional manual** full regen + `:screenshots_only` upload — not by every PR.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Full run — iterates over ALL_LOCALES in batches of BATCH_SIZE.
|
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
|
||||||
# The script prints "Locales: N | Batch size: B | Batches: ceil(N/B)" at startup.
|
# Use this for local iteration and PRs that touch screenshot content.
|
||||||
./fastlane/generate_screenshots.sh
|
|
||||||
|
|
||||||
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
|
|
||||||
./fastlane/generate_screenshots.sh --smoke
|
./fastlane/generate_screenshots.sh --smoke
|
||||||
|
|
||||||
|
# Full run — all 68 locales. Use only when intending to upload to Play Store
|
||||||
|
# (the non-smoke output is .gitignored and should not be committed).
|
||||||
|
./fastlane/generate_screenshots.sh
|
||||||
|
|
||||||
# Copy into fastlane directories (run after generate)
|
# Copy into fastlane directories (run after generate)
|
||||||
./fastlane/copy_screenshots.sh
|
./fastlane/copy_screenshots.sh
|
||||||
|
|
||||||
@@ -48,23 +55,36 @@ ScreenshotContent.kt (mock data + composables)
|
|||||||
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
||||||
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
|
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
|
||||||
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
|
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
|
||||||
5. Run the full pipeline: `generate_screenshots.sh` then `copy_screenshots.sh`
|
5. Run the smoke pipeline: `generate_screenshots.sh --smoke` then `copy_screenshots.sh --clean`
|
||||||
|
|
||||||
## Removing or Renaming a Screenshot
|
## Removing or Renaming a Screenshot
|
||||||
|
|
||||||
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
|
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
|
||||||
2. Run `generate_screenshots.sh`
|
2. Run `generate_screenshots.sh --smoke`
|
||||||
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/*/images/phoneScreenshots/` and get uploaded to Play Store
|
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
|
||||||
|
|
||||||
## After UI Changes
|
## After UI Changes
|
||||||
|
|
||||||
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate:
|
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate the smoke set:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./fastlane/generate_screenshots.sh
|
./fastlane/generate_screenshots.sh --smoke
|
||||||
./fastlane/copy_screenshots.sh --clean
|
./fastlane/copy_screenshots.sh --clean
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Refreshing all locales on Play Store
|
||||||
|
|
||||||
|
Periodic, manual operation — not per-PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fastlane/generate_screenshots.sh # full, ~30 min, 477 PNGs
|
||||||
|
./fastlane/copy_screenshots.sh --clean
|
||||||
|
bundle exec fastlane screenshots_only # uploads all 68 locales to Play Store
|
||||||
|
git checkout -- fastlane/metadata/android/ # discard non-smoke changes (gitignored anyway)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.gitignore` rule keeps non-smoke output unstaged automatically, so only the smoke locales' refreshed PNGs would show up as modifications and can be committed.
|
||||||
|
|
||||||
## Technical Notes
|
## Technical Notes
|
||||||
|
|
||||||
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
|
- 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)
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ name: Code tests & eval
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
paths-ignore:
|
||||||
|
- VERSION
|
||||||
|
- version.properties
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
@@ -101,4 +104,4 @@ jobs:
|
|||||||
run: bats tools/release/bump.bats
|
run: bats tools/release/bump.bats
|
||||||
|
|
||||||
- name: Verify version.properties + VERSION are consistent
|
- name: Verify version.properties + VERSION are consistent
|
||||||
run: ./tools/release/bump.sh --mode=check
|
run: ./tools/release/bump.sh --mode=check
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- VERSION
|
||||||
|
- version.properties
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ name: Deploy GitHub Pages
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
paths:
|
||||||
|
- _config.yml
|
||||||
|
- _layouts/**
|
||||||
|
- README.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
- PRIVACY_POLICY.md
|
||||||
|
- CNAME
|
||||||
|
- .github/workflows/pages.yml
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ jobs:
|
|||||||
id: app-token
|
id: app-token
|
||||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
|
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
|
||||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Resolve bot identity
|
- name: Resolve bot identity
|
||||||
@@ -164,12 +164,12 @@ jobs:
|
|||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
- name: Re-validate after approval wait
|
- name: Verify state still matches plan from Job 1
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
|
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
|
||||||
|
|
||||||
- name: Re-check tag collision (state may have moved during approval)
|
- name: Re-check tag collision (state may have moved between jobs)
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
|
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
|
||||||
|
|||||||
+10
@@ -17,6 +17,16 @@
|
|||||||
.kotlin
|
.kotlin
|
||||||
# Screenshot test reference images (ephemeral, regenerated on demand)
|
# Screenshot test reference images (ephemeral, regenerated on demand)
|
||||||
app/src/screenshotTest*/reference/
|
app/src/screenshotTest*/reference/
|
||||||
|
# Play Store phone screenshots: commit only the 6 smoke locales (mirrors permission-pilot).
|
||||||
|
# Full localization is uploaded by occasional manual regen + screenshots_only;
|
||||||
|
# Play Store retains previously-uploaded screenshots for locales not pushed.
|
||||||
|
fastlane/metadata/android/*/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/en-US/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/de-DE/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/ja-JP/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/ar/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/zh-CN/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/pt-BR/images/phoneScreenshots/*.png
|
||||||
.codex
|
.codex
|
||||||
protocol-research/
|
protocol-research/
|
||||||
_site/
|
_site/
|
||||||
|
|||||||
@@ -81,8 +81,7 @@ Currently supported models:
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
|
<img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1_dashboard_light.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/2_dashboard_dark.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3_case_popup.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/4_widget_configuration.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5_device_profiles.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/6_add_profile.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/7_device_settings_reactions.png" width="100">
|
||||||
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
|
|
||||||
|
|
||||||
## Thanks to
|
## Thanks to
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
@@ -130,6 +130,7 @@ android {
|
|||||||
//noinspection WrongGradleMethod
|
//noinspection WrongGradleMethod
|
||||||
tasks.withType<Test> {
|
tasks.withType<Test> {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
|
maxHeapSize = "4g"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.compose.preview.MOCK_NOW
|
import eu.darken.capod.common.compose.preview.MOCK_NOW
|
||||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
@@ -37,17 +38,26 @@ import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
|
|||||||
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DashboardContent() = PreviewWrapper {
|
internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
|
||||||
|
val devices = if (showAap) {
|
||||||
|
listOf(
|
||||||
|
MockPodDataProvider.dualPodMonitoredWithAap(),
|
||||||
|
MockPodDataProvider.singlePodMonitoredWithAap(),
|
||||||
|
MockPodDataProvider.unknownMonitored(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||||
|
MockPodDataProvider.singlePodMonitored(),
|
||||||
|
MockPodDataProvider.unknownMonitored(),
|
||||||
|
)
|
||||||
|
}
|
||||||
OverviewScreen(
|
OverviewScreen(
|
||||||
state = OverviewViewModel.State(
|
state = OverviewViewModel.State(
|
||||||
now = MOCK_NOW,
|
now = MOCK_NOW,
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = listOf(
|
devices = devices,
|
||||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
isDebug = false,
|
||||||
MockPodDataProvider.singlePodMonitored(),
|
|
||||||
MockPodDataProvider.unknownMonitored(),
|
|
||||||
),
|
|
||||||
isDebugMode = false,
|
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = listOf(
|
profiles = listOf(
|
||||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||||
@@ -83,19 +93,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AddProfileContent() = PreviewWrapper {
|
internal fun AddProfileContent() = PreviewWrapper {
|
||||||
|
val pairedAirPods = BluetoothDevice2(
|
||||||
|
address = "AA:BB:CC:DD:EE:FF",
|
||||||
|
name = "AirPods Pro",
|
||||||
|
seenFirstAt = MOCK_NOW,
|
||||||
|
)
|
||||||
|
val bondedItems = listOf(
|
||||||
|
DeviceProfileCreationViewModel.BondedDeviceItem(
|
||||||
|
device = pairedAirPods,
|
||||||
|
claimedByProfile = null,
|
||||||
|
),
|
||||||
|
DeviceProfileCreationViewModel.BondedDeviceItem(
|
||||||
|
device = BluetoothDevice2(
|
||||||
|
address = "11:22:33:44:55:66",
|
||||||
|
name = "Living Room TV",
|
||||||
|
seenFirstAt = MOCK_NOW,
|
||||||
|
),
|
||||||
|
claimedByProfile = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
DeviceProfileCreationScreen(
|
DeviceProfileCreationScreen(
|
||||||
state = DeviceProfileCreationViewModel.State(
|
state = DeviceProfileCreationViewModel.State(
|
||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
name = "",
|
name = "My AirPods Pro",
|
||||||
nameError = null,
|
nameError = null,
|
||||||
selectedModel = null,
|
selectedModel = PodModel.AIRPODS_PRO2,
|
||||||
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
||||||
identityKey = null,
|
identityKey = null,
|
||||||
encryptionKey = null,
|
encryptionKey = null,
|
||||||
selectedDevice = null,
|
selectedDevice = pairedAirPods,
|
||||||
bondedDeviceItems = emptyList(),
|
bondedDeviceItems = bondedItems,
|
||||||
minimumSignalQuality = 0.15f,
|
minimumSignalQuality = 0.15f,
|
||||||
canSave = false,
|
canSave = true,
|
||||||
),
|
),
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onSave = {},
|
onSave = {},
|
||||||
@@ -212,7 +241,7 @@ private fun PreviewDashboardLight() = DashboardContent()
|
|||||||
|
|
||||||
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
|
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun PreviewDashboardDark() = DashboardContent()
|
private fun PreviewDashboardDark() = DashboardContent(showAap = true)
|
||||||
|
|
||||||
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
|
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="foss_upgrade_donate_label">تبرُّع</string>
|
<string name="foss_upgrade_donate_label">تبرع</string>
|
||||||
<string name="foss_upgrade_alreadydonated_label">أنا متبرع بالفعل</string>
|
<string name="foss_upgrade_alreadydonated_label">أنا متبرّع فعلًا</string>
|
||||||
<string name="foss_upgrade_no_money_label">أُنفق كل أموالي على AirPods</string>
|
<string name="foss_upgrade_no_money_label">أنفق كل أموالي على AirPods</string>
|
||||||
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيداً، فكر في دعم التطوير للمساعدة في استمرار المشروع.</string>
|
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
|
||||||
<string name="upgrade_foss_sponsor_action">ادعم التطوير</string>
|
<string name="upgrade_foss_sponsor_action">دعم المشروع</string>
|
||||||
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود على Google Play.</string>
|
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود من Google Play.</string>
|
||||||
<string name="upgrade_foss_sponsor_returned_early">عدت بسرعة؟ دعمك يبقي CAPod حياً.</string>
|
<string name="upgrade_foss_sponsor_returned_early">هل عدت فعلًا؟ دعمكم يُبقي CAPod حيًا.</string>
|
||||||
<string name="upgrade_badge_label">البرمجيات الحرة (FOSS)</string>
|
<string name="upgrade_badge_label">البرمجيات الحرة (FOSS)</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متاحة.</string>
|
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متوفّرة.</string>
|
||||||
<string name="upgrades_no_purchases_found_check_account">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
|
<string name="upgrades_no_purchases_found_check_account">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
|
||||||
<string name="upgrades_gplay_billing_error_label">خطأ في Google Play</string>
|
<string name="upgrades_gplay_billing_error_label">خطأ في Google Play</string>
|
||||||
<string name="upgrades_gplay_billing_error_description">حدث خطأ في Google Play. يرجى المحاولة مرة أخرى لاحقًا أو إعادة تشغيل هاتفك.\n\nخطأ: %s</string>
|
<string name="upgrades_gplay_billing_error_description">حدث خطأ في Google Play. يُرجى المحاولة لاحقًا أو إعادة تشغيل هاتفك.\n\nخطأ: %s</string>
|
||||||
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة Google Play</string>
|
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة Google Play</string>
|
||||||
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ عند طلب تفاصيل الشراء من Google Play. امسح ذاكرة التخزين المؤقت لـ Google Play وأعد تشغيل هاتفك.\n\nخطأ %s</string>
|
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ أثناء طلب تفاصيل عملية الشراء من Google Play. يُرجى مسح ذاكرة التخزين المؤقّت لـ Google Play وإعادة تشغيل هاتفك.\n\nخطأ %s</string>
|
||||||
<string name="upgrade_restore_action">استعادة المُشتَرَى</string>
|
<string name="upgrade_restore_action">استعادة المشتريات</string>
|
||||||
<string name="upgrade_badge_label">احترافي</string>
|
<string name="upgrade_badge_label">النسخة الاحترافية</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -141,6 +141,20 @@
|
|||||||
android:name=".monitor.core.worker.MonitorService"
|
android:name=".monitor.core.worker.MonitorService"
|
||||||
android:foregroundServiceType="connectedDevice"
|
android:foregroundServiceType="connectedDevice"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".main.ui.tile.AncTileService"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@drawable/ic_anc_on"
|
||||||
|
android:label="@string/tile_anc_label"
|
||||||
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.service.quicksettings.ACTIVE_TILE"
|
||||||
|
android:value="false" />
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
package eu.darken.capod
|
package eu.darken.capod
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.Looper
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
import eu.darken.capod.common.debug.Bugs
|
|
||||||
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
||||||
import eu.darken.capod.common.debug.logging.LogCatLogger
|
import eu.darken.capod.common.debug.logging.LogCatLogger
|
||||||
import eu.darken.capod.common.debug.logging.Logging
|
import eu.darken.capod.common.debug.logging.Logging
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
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.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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.throttleLatest
|
import eu.darken.capod.common.flow.throttleLatest
|
||||||
@@ -22,16 +18,16 @@ import eu.darken.capod.monitor.core.DeviceMonitor
|
|||||||
|
|
||||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.system.exitProcess
|
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
open class App : Application() {
|
open class App : Application() {
|
||||||
@@ -46,22 +42,19 @@ open class App : Application() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
|
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
|
||||||
|
|
||||||
val foregroundExceptionHandled = AtomicBoolean(false)
|
|
||||||
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
|
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler(
|
||||||
val isTimingExc = throwable.isForegroundServiceTimingException()
|
CapodUncaughtExceptionHandler(
|
||||||
val isMain = thread === Looper.getMainLooper().thread
|
previousHandler = oldHandler,
|
||||||
if (isTimingExc && isMain && foregroundExceptionHandled.compareAndSet(false, true)) {
|
cancelBeforeDelegate = { throwable ->
|
||||||
runCatching {
|
// Best-effort shutdown: the system handler may terminate the process immediately,
|
||||||
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
// but cancellation can still close sockets if it gets a scheduling window.
|
||||||
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable)
|
if (::appScope.isInitialized) {
|
||||||
}
|
appScope.cancel(CancellationException("Uncaught exception", throwable))
|
||||||
Looper.loop()
|
}
|
||||||
return@setDefaultUncaughtExceptionHandler
|
},
|
||||||
}
|
)
|
||||||
runCatching { log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
|
)
|
||||||
if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
autoReporting.setup(this)
|
autoReporting.setup(this)
|
||||||
|
|
||||||
@@ -90,14 +83,5 @@ open class App : Application() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val TAG = logTag("CAP")
|
internal val TAG = logTag("CAP")
|
||||||
|
|
||||||
private fun Throwable.isForegroundServiceTimingException(): Boolean {
|
|
||||||
var current: Throwable? = this
|
|
||||||
while (current != null) {
|
|
||||||
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
|
|
||||||
current = current.cause
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package eu.darken.capod
|
||||||
|
|
||||||
|
import android.os.Looper
|
||||||
|
import eu.darken.capod.common.debug.Bugs
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
internal class CapodUncaughtExceptionHandler(
|
||||||
|
private val previousHandler: Thread.UncaughtExceptionHandler?,
|
||||||
|
private val mainThreadProvider: () -> Thread = { Looper.getMainLooper().thread },
|
||||||
|
private val loopMainThread: () -> Unit = { Looper.loop() },
|
||||||
|
private val reportForegroundServiceTimingException: (Throwable) -> Unit = { throwable ->
|
||||||
|
Bugs.report(
|
||||||
|
tag = App.TAG,
|
||||||
|
message = "Foreground service timing exception suppressed",
|
||||||
|
exception = throwable,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
private val cancelBeforeDelegate: (Throwable) -> Unit = {},
|
||||||
|
private val exit: (Int) -> Unit = { exitProcess(it) },
|
||||||
|
) : Thread.UncaughtExceptionHandler {
|
||||||
|
|
||||||
|
private val foregroundExceptionHandled = AtomicBoolean(false)
|
||||||
|
|
||||||
|
override fun uncaughtException(thread: Thread, throwable: Throwable) {
|
||||||
|
if (shouldSuppress(thread, throwable)) {
|
||||||
|
runCatching {
|
||||||
|
log(App.TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
||||||
|
reportForegroundServiceTimingException(throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
val loopResult = runCatching { loopMainThread() }
|
||||||
|
if (loopResult.isSuccess) return
|
||||||
|
|
||||||
|
val loopFailure = loopResult.exceptionOrNull()!!
|
||||||
|
runCatching {
|
||||||
|
log(App.TAG, ERROR) {
|
||||||
|
"Main loop failed after foreground service timing exception suppression: ${loopFailure.asLog()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delegate(thread, loopFailure)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching { log(App.TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
|
||||||
|
delegate(thread, throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldSuppress(thread: Thread, throwable: Throwable): Boolean {
|
||||||
|
val isMainThread = runCatching { thread === mainThreadProvider() }.getOrDefault(false)
|
||||||
|
return throwable.isForegroundServiceTimingException() &&
|
||||||
|
isMainThread &&
|
||||||
|
foregroundExceptionHandled.compareAndSet(false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun delegate(thread: Thread, throwable: Throwable) {
|
||||||
|
runCatching { cancelBeforeDelegate(throwable) }
|
||||||
|
previousHandler?.uncaughtException(thread, throwable) ?: exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Throwable.isForegroundServiceTimingException(): Boolean {
|
||||||
|
var current: Throwable? = this
|
||||||
|
while (current != null) {
|
||||||
|
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
|
||||||
|
current = current.cause
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package eu.darken.capod.common
|
package eu.darken.capod.common
|
||||||
|
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
|
import android.media.AudioPlaybackConfiguration
|
||||||
|
import android.os.Build
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||||
|
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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -14,48 +17,103 @@ class MediaControl @Inject constructor(
|
|||||||
private val audioManager: AudioManager,
|
private val audioManager: AudioManager,
|
||||||
private val timeSource: TimeSource,
|
private val timeSource: TimeSource,
|
||||||
) {
|
) {
|
||||||
private var capPauseExpiryElapsedRealtime: Long = 0L
|
/**
|
||||||
|
* Set when [sendPause] dispatches a pause we expect to take effect, cleared when [sendPlay]
|
||||||
|
* dispatches a resume or when music transitions inactive→active from any source. Read by
|
||||||
|
* [PlayPause] to gate auto-resume on pod-in: only resume if we're the ones who paused.
|
||||||
|
*
|
||||||
|
* "Sticky" — no time-based expiry. The original 15-second window was too short for typical
|
||||||
|
* pod-out conversations and had a known race where music starting again via another source
|
||||||
|
* (e.g. user manually resumed) could trigger a stray play-key dispatch on the next pod-in.
|
||||||
|
*/
|
||||||
|
@Volatile private var capPaused: Boolean = false
|
||||||
|
@Volatile private var lastKnownMusicActive: Boolean = false
|
||||||
|
|
||||||
|
private val playbackCallback = object : AudioManager.AudioPlaybackCallback() {
|
||||||
|
override fun onPlaybackConfigChanged(configs: List<AudioPlaybackConfiguration>) {
|
||||||
|
val nowActive = audioManager.isMusicActive
|
||||||
|
if (!lastKnownMusicActive && nowActive) {
|
||||||
|
// Music started by some source (could be us via sendPlay or someone else).
|
||||||
|
// Either way, our pause memory is stale — drop it so a future pod-in doesn't
|
||||||
|
// re-fire a play key over already-active music.
|
||||||
|
capPaused = false
|
||||||
|
}
|
||||||
|
lastKnownMusicActive = nowActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Seed the active flag from current state so we won't miss the next inactive→active
|
||||||
|
// transition if music is already playing when MediaControl is constructed.
|
||||||
|
lastKnownMusicActive = audioManager.isMusicActive
|
||||||
|
audioManager.registerAudioPlaybackCallback(playbackCallback, null)
|
||||||
|
}
|
||||||
|
|
||||||
val isPlaying: Boolean
|
val isPlaying: Boolean
|
||||||
get() = audioManager.isMusicActive
|
get() = audioManager.isMusicActive
|
||||||
|
|
||||||
val wasRecentlyPausedByCap: Boolean
|
val wasRecentlyPausedByCap: Boolean
|
||||||
get() = capPauseExpiryElapsedRealtime > timeSource.elapsedRealtime()
|
get() = capPaused
|
||||||
|
|
||||||
suspend fun sendPlay() {
|
suspend fun sendPlay() {
|
||||||
log(TAG, INFO) { "sendPlay()" }
|
log(TAG, INFO) { "sendPlay()" }
|
||||||
if (audioManager.isMusicActive && !wasRecentlyPausedByCap) {
|
if (audioManager.isMusicActive && !capPaused) {
|
||||||
log(TAG, INFO) { "Music is already playing, not sending play" }
|
log(TAG, INFO) { "Music is already playing, not sending play" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
|
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
|
||||||
clearRecentCapPause()
|
capPaused = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
|
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
|
||||||
*
|
*
|
||||||
* Returns `true` when a key event was actually dispatched (and the 15-second
|
* @param rememberForResume When `true`, arms [wasRecentlyPausedByCap] so a subsequent
|
||||||
* [wasRecentlyPausedByCap] window was set), `false` when the call was a no-op because
|
* pod-in transition can auto-resume — set this only from the auto-pause / ear-detection
|
||||||
* nothing was playing. Callers that need to distinguish "we actually paused" from "there
|
* flow. When `false` (default), the dispatched pause clears any pending auto-resume —
|
||||||
* was nothing to pause" — e.g. the sleep reaction, which gates its notification and
|
* this is the path for stem-press play/pause, sleep detection, and anywhere else CAPod
|
||||||
* cooldown on a real pause — should branch on the return value rather than checking
|
* is relaying an explicit user choice to stop playback. Matches Apple's iOS/macOS
|
||||||
* [isPlaying] themselves to avoid a check-then-act race with the audio system.
|
* behavior where only ear-removal auto-pauses are eligible for auto-resume.
|
||||||
|
*
|
||||||
|
* Returns `true` when a key event was actually dispatched, `false` when the call was a
|
||||||
|
* no-op because nothing was playing. A no-op leaves the existing flag state untouched
|
||||||
|
* (so a sleep-reaction firing while music is already paused doesn't accidentally cancel
|
||||||
|
* a pending auto-resume from a recent ear-removal pause). 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 {
|
suspend fun sendPause(rememberForResume: Boolean = false): Boolean {
|
||||||
log(TAG, INFO) { "sendPause()" }
|
log(TAG, INFO) { "sendPause(rememberForResume=$rememberForResume)" }
|
||||||
if (!audioManager.isMusicActive) {
|
if (!audioManager.isMusicActive) {
|
||||||
log(TAG, INFO) { "Music is not playing, not sending pause" }
|
log(TAG, INFO) { "Music is not playing, not sending pause" }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// Set BEFORE the suspending sendKey() call. If we set after, an inactive→active
|
||||||
|
// playback callback that fires during the dispatch (e.g. a fast user resume on the
|
||||||
|
// phone, or another app grabbing audio focus and immediately starting) could clear
|
||||||
|
// capPaused mid-dispatch and we'd then overwrite it back to true on a stale pause.
|
||||||
|
// This single explicit assignment also covers the contract that an explicit user
|
||||||
|
// pause cancels a pending auto-resume.
|
||||||
|
capPaused = rememberForResume
|
||||||
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
|
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
|
||||||
markRecentCapPause()
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches MEDIA_STOP and clears any pending auto-resume — Stop is an explicit user
|
||||||
|
* "stay stopped" action, so a later pod-in must not auto-resume from a prior auto-pause.
|
||||||
|
*/
|
||||||
|
suspend fun sendStop() {
|
||||||
|
log(TAG, INFO) { "sendStop()" }
|
||||||
|
capPaused = false
|
||||||
|
sendKey(KeyEvent.KEYCODE_MEDIA_STOP)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun sendPlayPause() {
|
suspend fun sendPlayPause() {
|
||||||
log(TAG) { "sendPlayPause()" }
|
log(TAG) { "sendPlayPause()" }
|
||||||
if (wasRecentlyPausedByCap) {
|
if (capPaused) {
|
||||||
sendPlay()
|
sendPlay()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -101,16 +159,71 @@ class MediaControl @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun markRecentCapPause() {
|
/** Current STREAM_MUSIC volume index. Used to detect user-initiated volume changes after a duck. */
|
||||||
capPauseExpiryElapsedRealtime = timeSource.elapsedRealtime() + RECENT_CAP_PAUSE_WINDOW_MS
|
fun currentMusicVolume(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and returns
|
||||||
|
* the prior + the volume actually applied, so the caller can later restore it and detect whether
|
||||||
|
* the user changed the volume in the meantime.
|
||||||
|
*
|
||||||
|
* Returns `null` (no-op) when nothing is playing, the device has fixed volume, or the computed
|
||||||
|
* target wouldn't actually lower the volume. No [AudioManager.FLAG_SHOW_UI] — this fires on a
|
||||||
|
* frequent push event and the volume panel flashing would be noisy. The applied target is read
|
||||||
|
* back from the system because Bluetooth absolute-volume routes can quantize the requested value.
|
||||||
|
*/
|
||||||
|
fun duckMusicVolume(reductionPercent: Int): VolumeDuck? {
|
||||||
|
if (!audioManager.isMusicActive) {
|
||||||
|
log(TAG, INFO) { "duckMusicVolume: nothing playing, skipping" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (audioManager.isVolumeFixed) {
|
||||||
|
log(TAG, INFO) { "duckMusicVolume: device has fixed volume, skipping" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val percent = reductionPercent.coerceIn(0, 100)
|
||||||
|
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
val min = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
val prior = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
val target = (prior * (100 - percent) / 100).coerceIn(min, max)
|
||||||
|
if (target >= prior) {
|
||||||
|
log(TAG, INFO) { "duckMusicVolume: target $target >= current $prior, skipping" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
|
||||||
|
val applied = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" }
|
||||||
|
VolumeDuck(priorVolume = prior, appliedVolume = applied)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// setStreamVolume throws under Do-Not-Disturb without notification policy access.
|
||||||
|
log(TAG, WARN) { "duckMusicVolume: setStreamVolume denied: ${e.message}" }
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearRecentCapPause() {
|
/** Restores STREAM_MUSIC to [priorVolume]. No-op on fixed-volume devices; denials are logged. */
|
||||||
capPauseExpiryElapsedRealtime = 0L
|
fun restoreMusicVolume(priorVolume: Int) {
|
||||||
|
if (audioManager.isVolumeFixed) return
|
||||||
|
try {
|
||||||
|
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, priorVolume, 0)
|
||||||
|
log(TAG, INFO) { "restoreMusicVolume($priorVolume)" }
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
log(TAG, WARN) { "restoreMusicVolume: setStreamVolume denied: ${e.message}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot of a volume duck so the caller can restore the prior level and detect user changes. */
|
||||||
|
data class VolumeDuck(
|
||||||
|
val priorVolume: Int,
|
||||||
|
val appliedVolume: Int,
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("MediaControl")
|
private val TAG = logTag("MediaControl")
|
||||||
private const val RECENT_CAP_PAUSE_WINDOW_MS = 15_000L
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -32,7 +33,6 @@ import javax.inject.Singleton
|
|||||||
class BleScanner @Inject constructor(
|
class BleScanner @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val bluetoothManager: BluetoothManager2,
|
private val bluetoothManager: BluetoothManager2,
|
||||||
private val fakeBleData: FakeBleData,
|
|
||||||
private val scanResultForwarder: BleScanResultForwarder,
|
private val scanResultForwarder: BleScanResultForwarder,
|
||||||
private val timeSource: TimeSource,
|
private val timeSource: TimeSource,
|
||||||
) {
|
) {
|
||||||
@@ -113,22 +113,7 @@ class BleScanner @Inject constructor(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val flushJob = if (!disableDirectScanCallback) {
|
var flushJob: Job? = null
|
||||||
launch {
|
|
||||||
log(TAG) { "Flush job launched" }
|
|
||||||
while (isActive) {
|
|
||||||
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
|
|
||||||
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
|
|
||||||
when (scannerMode) {
|
|
||||||
ScannerMode.LOW_POWER -> break
|
|
||||||
ScannerMode.BALANCED -> delay(2000)
|
|
||||||
ScannerMode.LOW_LATENCY -> delay(500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val filterList = when {
|
val filterList = when {
|
||||||
useOffloadedFiltering -> filters.toList()
|
useOffloadedFiltering -> filters.toList()
|
||||||
@@ -167,31 +152,59 @@ class BleScanner @Inject constructor(
|
|||||||
setReportDelay(delay)
|
setReportDelay(delay)
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
if (disableDirectScanCallback) {
|
try {
|
||||||
val callbackIntent = createStartIntent()
|
if (disableDirectScanCallback) {
|
||||||
log(TAG) {
|
val callbackIntent = createStartIntent()
|
||||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
|
log(TAG) {
|
||||||
|
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
|
||||||
|
}
|
||||||
|
scanner.startScan(filterList, scanSettings, callbackIntent)
|
||||||
|
} else {
|
||||||
|
log(TAG) {
|
||||||
|
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
|
||||||
|
}
|
||||||
|
scanner.startScan(filterList, scanSettings, callback)
|
||||||
|
flushJob = launch {
|
||||||
|
log(TAG) { "Flush job launched" }
|
||||||
|
while (isActive) {
|
||||||
|
try {
|
||||||
|
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
|
||||||
|
scanner.flushPendingScanResults(callback)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
log(TAG, WARN) { "flushPendingScanResults() denied: ${e.message}" }
|
||||||
|
close(e)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
when (scannerMode) {
|
||||||
|
ScannerMode.LOW_POWER -> break
|
||||||
|
ScannerMode.BALANCED -> delay(2000)
|
||||||
|
ScannerMode.LOW_LATENCY -> delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
scanner.startScan(filterList, scanSettings, callbackIntent)
|
} catch (e: SecurityException) {
|
||||||
} else {
|
log(TAG, WARN) { "startScan() denied: ${e.message}" }
|
||||||
log(TAG) {
|
forwarderConsumer?.cancel()
|
||||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
|
close(e)
|
||||||
}
|
return@callbackFlow
|
||||||
scanner.startScan(filterList, scanSettings, callback)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
awaitClose {
|
awaitClose {
|
||||||
forwarderConsumer?.cancel()
|
forwarderConsumer?.cancel()
|
||||||
flushJob?.cancel()
|
flushJob?.cancel()
|
||||||
if (disableDirectScanCallback) {
|
try {
|
||||||
scanner.stopScan(createStopIntent())
|
if (disableDirectScanCallback) {
|
||||||
} else {
|
scanner.stopScan(createStopIntent())
|
||||||
scanner.stopScan(callback)
|
} else {
|
||||||
|
scanner.stopScan(callback)
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
log(TAG, WARN) { "stopScan() denied: ${e.message}" }
|
||||||
}
|
}
|
||||||
log(TAG) { "BleScanner stopped" }
|
log(TAG) { "BleScanner stopped" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.map { fakeBleData.maybeAddfakeData(it) }
|
|
||||||
|
|
||||||
private val receiverIntent by lazy {
|
private val receiverIntent by lazy {
|
||||||
Intent(context, BleScanResultReceiver::class.java).apply {
|
Intent(context, BleScanResultReceiver::class.java).apply {
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
data class BluetoothDevice2(
|
data class BluetoothDevice2(
|
||||||
internal val internal: BluetoothDevice,
|
val address: BluetoothAddress,
|
||||||
|
val name: String?,
|
||||||
val seenFirstAt: Instant,
|
val seenFirstAt: Instant,
|
||||||
) {
|
internal val internal: BluetoothDevice? = null,
|
||||||
val address: BluetoothAddress
|
)
|
||||||
get() = internal.address
|
|
||||||
|
|
||||||
val name: String?
|
|
||||||
get() = internal.name
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.HandlerThread
|
import android.os.HandlerThread
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
@@ -23,6 +24,7 @@ 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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||||
|
import eu.darken.capod.common.permissions.Permission
|
||||||
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
|
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
@@ -255,6 +257,8 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
}
|
}
|
||||||
.map { device ->
|
.map { device ->
|
||||||
BluetoothDevice2(
|
BluetoothDevice2(
|
||||||
|
address = device.address,
|
||||||
|
name = device.name,
|
||||||
internal = device,
|
internal = device,
|
||||||
seenFirstAt = seenDevicesLock.withLock {
|
seenFirstAt = seenDevicesLock.withLock {
|
||||||
seenDevicesCache[device.address] ?: run {
|
seenDevicesCache[device.address] ?: run {
|
||||||
@@ -304,6 +308,8 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
val wrappedDevices = rawDevices.map { device ->
|
val wrappedDevices = rawDevices.map { device ->
|
||||||
|
|
||||||
BluetoothDevice2(
|
BluetoothDevice2(
|
||||||
|
address = device.address,
|
||||||
|
name = device.name,
|
||||||
internal = device,
|
internal = device,
|
||||||
seenFirstAt = seenDevicesLock.withLock {
|
seenFirstAt = seenDevicesLock.withLock {
|
||||||
seenDevicesCache[device.address] ?: run {
|
seenDevicesCache[device.address] ?: run {
|
||||||
@@ -386,9 +392,6 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
initialValue = emptySet(),
|
initialValue = emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private var _isNudgeAvailable: Boolean = true
|
|
||||||
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth
|
* Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth
|
||||||
* settings display without touching the AirPods firmware itself. Uses reflection on the hidden
|
* settings display without touching the AirPods firmware itself. Uses reflection on the hidden
|
||||||
@@ -408,9 +411,10 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
@android.annotation.SuppressLint("MissingPermission")
|
@android.annotation.SuppressLint("MissingPermission")
|
||||||
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
||||||
return try {
|
return try {
|
||||||
|
val target = device.internal ?: return false
|
||||||
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
||||||
.apply { isAccessible = true }
|
.apply { isAccessible = true }
|
||||||
val result = method.invoke(device.internal, alias) as? Boolean ?: false
|
val result = method.invoke(target, alias) as? Boolean ?: false
|
||||||
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
|
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
|
||||||
result
|
result
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -420,28 +424,42 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
suspend fun nudgeConnection(device: BluetoothDevice2): NudgeAttemptResult =
|
||||||
try {
|
getBluetoothProfile().map { bluetoothProfile ->
|
||||||
log(TAG) { "Nudging Android connection to $device" }
|
try {
|
||||||
|
log(TAG) { "Nudging Android connection to $device" }
|
||||||
|
|
||||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
val target = device.internal ?: return@map NudgeAttemptResult.Rejected
|
||||||
"connect", BluetoothDevice::class.java
|
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||||
).apply { isAccessible = true }
|
"connect", BluetoothDevice::class.java
|
||||||
|
).apply { isAccessible = true }
|
||||||
|
|
||||||
val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) as? Boolean ?: false
|
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
|
||||||
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
||||||
accepted
|
if (accepted) NudgeAttemptResult.Accepted else NudgeAttemptResult.Rejected
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val isSecurityException = e is SecurityException ||
|
val isSecurityException = e is SecurityException ||
|
||||||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
|
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
|
||||||
if (isSecurityException) {
|
if (!isSecurityException) {
|
||||||
log(TAG, ERROR) { "nudgeConnection is permanently unavailable: missing MODIFY_PHONE_STATE permission" }
|
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||||
_isNudgeAvailable = false
|
return@map NudgeAttemptResult.Rejected
|
||||||
|
}
|
||||||
|
// BLUETOOTH_CONNECT is only a runtime permission on Android 12+. On older
|
||||||
|
// versions a SecurityException can't be a missing-permission failure for that
|
||||||
|
// permission — it's hidden-API enforcement (e.g. MODIFY_PHONE_STATE) and we
|
||||||
|
// should persist BROKEN.
|
||||||
|
val missingBtConnect = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||||
|
!Permission.BLUETOOTH_CONNECT.isGranted(context)
|
||||||
|
if (missingBtConnect) {
|
||||||
|
log(TAG, WARN) { "nudgeConnection failed because BLUETOOTH_CONNECT is not granted" }
|
||||||
|
NudgeAttemptResult.UnavailableMissingPermission
|
||||||
|
} else {
|
||||||
|
log(TAG, ERROR) { "nudgeConnection is permanently unavailable on this device: $e" }
|
||||||
|
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||||
|
NudgeAttemptResult.UnavailableHiddenApi
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
}.first()
|
||||||
false
|
|
||||||
}
|
|
||||||
}.first()
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Bluetooth", "Manager2")
|
private val TAG = logTag("Bluetooth", "Manager2")
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
package eu.darken.capod.common.bluetooth
|
|
||||||
|
|
||||||
import dagger.Reusable
|
|
||||||
import eu.darken.capod.common.TimeSource
|
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
|
||||||
import eu.darken.capod.common.fromHex
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.random.Random
|
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
|
||||||
|
|
||||||
@Reusable
|
|
||||||
class FakeBleData @Inject constructor(
|
|
||||||
private val debugSettings: DebugSettings,
|
|
||||||
private val timeSource: TimeSource,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
|
|
||||||
if (!debugSettings.showFakeData.valueBlocking) return originals
|
|
||||||
return originals + getFakeData()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFakeData(): Collection<BleScanResult> {
|
|
||||||
val fakeDevices = mutableListOf<BleScanResult>()
|
|
||||||
val now = timeSource.now()
|
|
||||||
val generatedAt = timeSource.elapsedRealtimeNanos()
|
|
||||||
// AirPods Gen1
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "78:73:AF:B4:85:22",
|
|
||||||
rssi = Random.nextInt(100) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 100,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AirPods Gen2
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "78:73:FF:B4:85:5E",
|
|
||||||
rssi = Random.nextInt(100) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 100,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AirPods Gen3
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "4E:9E:D1:49:D2:6D",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 200,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
// AirPods Max
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "7E:E5:C7:65:D2:B5",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 300,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
// BeatsFlex
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "5E:9E:D1:49:D2:6D",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 400,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tws i99999
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "5E:9E:D1:29:D2:6D",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 400,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown Device
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "6E:9E:D1:49:D2:6D",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 500,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fakeDevices
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package eu.darken.capod.common.bluetooth
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class NudgeAvailability {
|
||||||
|
@SerialName("nudge.unknown") UNKNOWN,
|
||||||
|
@SerialName("nudge.available") AVAILABLE,
|
||||||
|
@SerialName("nudge.broken") BROKEN,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class NudgeAttemptResult {
|
||||||
|
Accepted,
|
||||||
|
Rejected,
|
||||||
|
UnavailableHiddenApi,
|
||||||
|
UnavailableMissingPermission,
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package eu.darken.capod.common.bluetooth
|
||||||
|
|
||||||
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
|
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.logging.Logging.Priority.INFO
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.main.core.GeneralSettings
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class NudgeCapabilityStore @Inject constructor(
|
||||||
|
generalSettings: GeneralSettings,
|
||||||
|
@AppScope private val appScope: CoroutineScope,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val persistedValue = generalSettings.nudgeAvailability
|
||||||
|
|
||||||
|
// Resolve the persisted value synchronously at construction so consumers that read
|
||||||
|
// `availability.value` (resolver, AutoConnect precondition, UI state) never see the
|
||||||
|
// synthetic UNKNOWN seed before the first DataStore emission arrives. On a known-broken
|
||||||
|
// device this prevents one bonus ALWAYS-mode + nudge-attempt cycle per cold start.
|
||||||
|
val availability: StateFlow<NudgeAvailability> = persistedValue.flow
|
||||||
|
.stateIn(
|
||||||
|
scope = appScope + dispatcherProvider.IO,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = persistedValue.valueBlocking,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun record(result: NudgeAttemptResult) {
|
||||||
|
val verdict = verdictFor(result) ?: return
|
||||||
|
if (availability.value == verdict) return
|
||||||
|
log(TAG, INFO) { "Recording nudge verdict: $verdict (from $result)" }
|
||||||
|
appScope.launch(dispatcherProvider.IO) {
|
||||||
|
persistedValue.value(verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("Bluetooth", "NudgeCapabilityStore")
|
||||||
|
|
||||||
|
internal fun verdictFor(result: NudgeAttemptResult): NudgeAvailability? = when (result) {
|
||||||
|
NudgeAttemptResult.Accepted -> NudgeAvailability.AVAILABLE
|
||||||
|
NudgeAttemptResult.UnavailableHiddenApi -> NudgeAvailability.BROKEN
|
||||||
|
NudgeAttemptResult.Rejected,
|
||||||
|
NudgeAttemptResult.UnavailableMissingPermission -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,18 +180,21 @@ object MockPodDataProvider {
|
|||||||
|
|
||||||
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-mixed",
|
profileId = "preview-dual-mixed",
|
||||||
|
label = "My AirPods Pro",
|
||||||
ble = airPodsProMixed(),
|
ble = airPodsProMixed(),
|
||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-keys",
|
profileId = "preview-dual-keys",
|
||||||
|
label = "My AirPods Pro",
|
||||||
ble = airPodsProWithKeys(),
|
ble = airPodsProWithKeys(),
|
||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-aap",
|
profileId = "preview-dual-aap",
|
||||||
|
label = "My AirPods Pro",
|
||||||
ble = airPodsProWithKeys(),
|
ble = airPodsProWithKeys(),
|
||||||
aap = AapPodState(
|
aap = AapPodState(
|
||||||
connectionState = AapPodState.ConnectionState.READY,
|
connectionState = AapPodState.ConnectionState.READY,
|
||||||
@@ -227,12 +230,14 @@ object MockPodDataProvider {
|
|||||||
|
|
||||||
fun singlePodMonitored(): PodDevice = PodDevice(
|
fun singlePodMonitored(): PodDevice = PodDevice(
|
||||||
profileId = "preview-single",
|
profileId = "preview-single",
|
||||||
|
label = "AirPods Max",
|
||||||
ble = airPodsMax(),
|
ble = airPodsMax(),
|
||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
||||||
profileId = "preview-single-aap",
|
profileId = "preview-single-aap",
|
||||||
|
label = "AirPods Max",
|
||||||
ble = airPodsMaxCharging(),
|
ble = airPodsMaxCharging(),
|
||||||
aap = AapPodState(
|
aap = AapPodState(
|
||||||
connectionState = AapPodState.ConnectionState.READY,
|
connectionState = AapPodState.ConnectionState.READY,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
|||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
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.log
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -23,9 +24,10 @@ class DataStoreValue<T>(
|
|||||||
) {
|
) {
|
||||||
val keyName: String get() = key.name
|
val keyName: String get() = key.name
|
||||||
|
|
||||||
val flow: Flow<T> = dataStore.data.map { prefs ->
|
val flow: Flow<T> = dataStore.data
|
||||||
reader(prefs[key])
|
.map { prefs -> prefs[key] }
|
||||||
}
|
.distinctUntilChanged()
|
||||||
|
.map { raw -> reader(raw) }
|
||||||
|
|
||||||
data class Updated<T>(val old: T, val new: T)
|
data class Updated<T>(val old: T, val new: T)
|
||||||
|
|
||||||
|
|||||||
@@ -5,23 +5,41 @@ 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.VERBOSE
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
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.asLog
|
||||||
|
import eu.darken.capod.common.debug.logging.asLogSummary
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
object Bugs {
|
object Bugs {
|
||||||
var reporter: AutomaticBugReporter? = null
|
var reporter: AutomaticBugReporter? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the debug log recorder is actively recording. Surfaces extra
|
||||||
|
* diagnostic UI (e.g. raw BLE payload hex on device cards) so users helping
|
||||||
|
* to add support for new headphones can capture useful data without a
|
||||||
|
* permanent debug toggle.
|
||||||
|
*/
|
||||||
|
val isDebug = MutableStateFlow(false)
|
||||||
|
|
||||||
fun report(
|
fun report(
|
||||||
tag: String,
|
tag: String,
|
||||||
message: String,
|
message: String,
|
||||||
exception: Throwable
|
exception: Throwable
|
||||||
) {
|
) {
|
||||||
log(TAG, VERBOSE) { "Reporting $exception" }
|
runCatching { log(TAG, VERBOSE) { "Reporting ${exception.asLogSummary()}" } }
|
||||||
log(tag, ERROR) { "$message\n${exception.asLog()}" }
|
runCatching { log(tag, ERROR) { "$message\n${exception.asLog()}" } }
|
||||||
|
|
||||||
reporter?.notify(exception) ?: run {
|
val bugReporter = reporter
|
||||||
log(TAG, WARN) { "Bug tracking not initialized yet." }
|
if (bugReporter == null) {
|
||||||
|
runCatching { log(TAG, WARN) { "Bug tracking not initialized yet." } }
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runCatching { bugReporter.notify(exception) }
|
||||||
|
.onFailure { failure ->
|
||||||
|
runCatching { log(TAG, WARN) { "Bug reporter failed: ${failure.asLog()}" } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val TAG = logTag("Bugs")
|
private val TAG = logTag("Bugs")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,4 @@ class DebugSettings @Inject constructor(
|
|||||||
// Reporting is opt-out for gplay, and opt-in for github builds
|
// Reporting is opt-out for gplay, and opt-in for github builds
|
||||||
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
|
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
|
||||||
)
|
)
|
||||||
val isDebugModeEnabled = dataStore.createValue("debug.mode.enabled", false)
|
|
||||||
|
|
||||||
val showFakeData = dataStore.createValue("debug.fakedata.enabled", false)
|
|
||||||
|
|
||||||
val showUnfiltered = dataStore.createValue("debug.blescanner.unfiltered.enabled", false)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,16 +61,19 @@ object Logging {
|
|||||||
message: String
|
message: String
|
||||||
) {
|
) {
|
||||||
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
||||||
snapshot
|
snapshot.forEach {
|
||||||
.filter { it.isLoggable(priority) }
|
val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
|
||||||
.forEach {
|
if (isLoggable) {
|
||||||
it.log(
|
runCatching {
|
||||||
priority = priority,
|
it.log(
|
||||||
tag = tag,
|
priority = priority,
|
||||||
metaData = metaData,
|
tag = tag,
|
||||||
message = message
|
metaData = metaData,
|
||||||
)
|
message = message
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearAll() {
|
fun clearAll() {
|
||||||
@@ -110,14 +113,28 @@ inline fun log(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Throwable.asLog(): String {
|
fun Throwable.asLog(): String = runCatching {
|
||||||
val stringWriter = StringWriter(256)
|
val stringWriter = StringWriter(256)
|
||||||
val printWriter = PrintWriter(stringWriter, false)
|
val printWriter = PrintWriter(stringWriter, false)
|
||||||
printStackTrace(printWriter)
|
printStackTrace(printWriter)
|
||||||
printWriter.flush()
|
printWriter.flush()
|
||||||
return stringWriter.toString()
|
stringWriter.toString()
|
||||||
|
}.getOrElse { renderFailure ->
|
||||||
|
"${asLogSummary()}\n<stacktrace unavailable: ${renderFailure.asLogSummary()}>"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Throwable.asLogSummary(): String {
|
||||||
|
val throwableClass = javaClass.name
|
||||||
|
val throwableMessage = safeMessage()
|
||||||
|
return if (throwableMessage.isNullOrBlank()) {
|
||||||
|
throwableClass
|
||||||
|
} else {
|
||||||
|
"$throwableClass: $throwableMessage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Throwable.safeMessage(): String? = runCatching { message }.getOrNull()
|
||||||
|
|
||||||
@PublishedApi
|
@PublishedApi
|
||||||
internal fun Any.logTagViaCallSite(): String {
|
internal fun Any.logTagViaCallSite(): String {
|
||||||
val javaClass = this::class.java
|
val javaClass = this::class.java
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import eu.darken.capod.common.SystemTimeSource
|
|||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
|
import eu.darken.capod.common.debug.Bugs
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
@@ -126,6 +127,10 @@ class RecorderModule @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(appScope)
|
.launchIn(appScope)
|
||||||
|
|
||||||
|
internalState.flow
|
||||||
|
.onEach { Bugs.isDebug.value = it.isRecording }
|
||||||
|
.launchIn(appScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createSessionDir(): File {
|
private fun createSessionDir(): File {
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ object Nav {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object General : Settings
|
data object General : Settings
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object Debug : Settings
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Support : Settings
|
data object Support : Settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package eu.darken.capod.common.permissions
|
package eu.darken.capod.common.permissions
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.PermissionChecker
|
||||||
import eu.darken.capod.common.BuildConfigWrap
|
import eu.darken.capod.common.BuildConfigWrap
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.withinApiLevel
|
import eu.darken.capod.common.withinApiLevel
|
||||||
@@ -18,7 +17,7 @@ enum class Permission(
|
|||||||
val permissionId: String,
|
val permissionId: String,
|
||||||
val isScanBlocking: Boolean = false,
|
val isScanBlocking: Boolean = false,
|
||||||
val isGranted: (Context) -> Boolean = {
|
val isGranted: (Context) -> Boolean = {
|
||||||
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
|
PermissionChecker.checkSelfPermission(it, permissionId) == PermissionChecker.PERMISSION_GRANTED
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
BLUETOOTH(
|
BLUETOOTH(
|
||||||
@@ -87,4 +86,4 @@ enum class Permission(
|
|||||||
fun Permission.isRequired(context: Context): Boolean = when {
|
fun Permission.isRequired(context: Context): Boolean = when {
|
||||||
!withinApiLevel(minApiLevel, maxApiLevel) -> false
|
!withinApiLevel(minApiLevel, maxApiLevel) -> false
|
||||||
else -> !isGranted(context)
|
else -> !isGranted(context)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import eu.darken.capod.common.BuildConfigWrap
|
import eu.darken.capod.common.BuildConfigWrap
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
|
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||||
import eu.darken.capod.common.datastore.createValue
|
import eu.darken.capod.common.datastore.createValue
|
||||||
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
|
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
|
||||||
import eu.darken.capod.common.serialization.SerializationCapod
|
import eu.darken.capod.common.serialization.SerializationCapod
|
||||||
@@ -36,8 +37,6 @@ class GeneralSettings @Inject constructor(
|
|||||||
|
|
||||||
private val dataStore: DataStore<Preferences> get() = context.dataStore
|
private val dataStore: DataStore<Preferences> get() = context.dataStore
|
||||||
|
|
||||||
val monitorMode = dataStore.createValue("core.monitor.mode", MonitorMode.AUTOMATIC, json, onErrorFallbackToDefault = true)
|
|
||||||
|
|
||||||
val useExtraMonitorNotification = dataStore.createValue("core.monitor.notification.connected", false)
|
val useExtraMonitorNotification = dataStore.createValue("core.monitor.notification.connected", false)
|
||||||
|
|
||||||
val keepConnectedNotificationAfterDisconnect =
|
val keepConnectedNotificationAfterDisconnect =
|
||||||
@@ -74,10 +73,18 @@ class GeneralSettings @Inject constructor(
|
|||||||
val isOffloadedBatchingDisabled = dataStore.createValue("core.compat.offloaded.batching.disabled", false)
|
val isOffloadedBatchingDisabled = dataStore.createValue("core.compat.offloaded.batching.disabled", false)
|
||||||
val useIndirectScanResultCallback = dataStore.createValue("core.compat.indirectcallback.enabled", false)
|
val useIndirectScanResultCallback = dataStore.createValue("core.compat.indirectcallback.enabled", false)
|
||||||
|
|
||||||
|
/** Runtime-detected verdict for whether `BluetoothHeadset.connect()` reflection works on this device. */
|
||||||
|
val nudgeAvailability = dataStore.createValue(
|
||||||
|
"core.bluetooth.nudge.availability", NudgeAvailability.UNKNOWN, json,
|
||||||
|
onErrorFallbackToDefault = true,
|
||||||
|
)
|
||||||
|
|
||||||
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
|
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
|
||||||
|
|
||||||
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
|
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
|
||||||
|
|
||||||
|
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
|
||||||
|
|
||||||
val themeMode = dataStore.createValue(
|
val themeMode = dataStore.createValue(
|
||||||
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
|
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
|
||||||
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
|
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
package eu.darken.capod.main.core
|
package eu.darken.capod.main.core
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import eu.darken.capod.R
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class MonitorMode(
|
enum class MonitorMode(
|
||||||
@StringRes val labelRes: Int
|
val intensity: Int,
|
||||||
) {
|
) {
|
||||||
@SerialName("monitor.mode.manual") MANUAL(
|
@SerialName("monitor.mode.manual") MANUAL(intensity = 0),
|
||||||
R.string.settings_monitor_mode_manual_label
|
@SerialName("monitor.mode.automatic") AUTOMATIC(intensity = 1),
|
||||||
),
|
@SerialName("monitor.mode.always") ALWAYS(intensity = 2),
|
||||||
@SerialName("monitor.mode.automatic") AUTOMATIC(
|
}
|
||||||
R.string.settings_monitor_mode_automatic_label
|
|
||||||
),
|
|
||||||
@SerialName("monitor.mode.always") ALWAYS(
|
|
||||||
R.string.settings_monitor_mode_always_label
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import eu.darken.capod.common.debug.logging.log
|
|||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.permissions.Permission
|
import eu.darken.capod.common.permissions.Permission
|
||||||
import eu.darken.capod.common.permissions.isRequired
|
import eu.darken.capod.common.permissions.isRequired
|
||||||
|
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||||
import eu.darken.capod.profiles.core.toReactionConfig
|
import eu.darken.capod.profiles.core.toReactionConfig
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -21,7 +22,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class PermissionTool @Inject constructor(
|
class PermissionTool @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val generalSettings: GeneralSettings,
|
private val monitorModeResolver: MonitorModeResolver,
|
||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
) {
|
) {
|
||||||
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
|
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
|
||||||
@@ -41,13 +42,11 @@ class PermissionTool @Inject constructor(
|
|||||||
|
|
||||||
val missingPermissions: Flow<Set<Permission>> = combine(
|
val missingPermissions: Flow<Set<Permission>> = combine(
|
||||||
permissionCheckTrigger,
|
permissionCheckTrigger,
|
||||||
generalSettings.monitorMode.flow,
|
monitorModeResolver.effectiveMode,
|
||||||
anyPopupEnabled,
|
anyPopupEnabled,
|
||||||
) { _, monitorMode, showPopUp ->
|
) { _, monitorMode, showPopUp ->
|
||||||
Permission.entries
|
Permission.entries
|
||||||
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS }
|
.filter { isApplicable(it, monitorMode, showPopUp) }
|
||||||
.filter { it != Permission.ACCESS_BACKGROUND_LOCATION || monitorMode == MonitorMode.ALWAYS }
|
|
||||||
.filter { it != Permission.SYSTEM_ALERT_WINDOW || showPopUp }
|
|
||||||
.filter { it.isRequired(context) }
|
.filter { it.isRequired(context) }
|
||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
@@ -61,5 +60,24 @@ class PermissionTool @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("PermissionTool")
|
private val TAG = logTag("PermissionTool")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [permission] is applicable for the user's current configuration.
|
||||||
|
* Three permissions are conditional on monitor mode or popup usage:
|
||||||
|
* - [Permission.IGNORE_BATTERY_OPTIMIZATION] only when always-on scanning is needed
|
||||||
|
* - [Permission.ACCESS_BACKGROUND_LOCATION] same
|
||||||
|
* - [Permission.SYSTEM_ALERT_WINDOW] only when at least one popup reaction is enabled
|
||||||
|
* Everything else is unconditionally applicable.
|
||||||
|
*/
|
||||||
|
internal fun isApplicable(
|
||||||
|
permission: Permission,
|
||||||
|
monitorMode: MonitorMode,
|
||||||
|
anyPopupEnabled: Boolean,
|
||||||
|
): Boolean = when (permission) {
|
||||||
|
Permission.IGNORE_BATTERY_OPTIMIZATION,
|
||||||
|
Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS
|
||||||
|
Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.common.flow.SingleEventFlow
|
||||||
import eu.darken.capod.common.navigation.LocalNavigationController
|
import eu.darken.capod.common.navigation.LocalNavigationController
|
||||||
import eu.darken.capod.common.navigation.Nav
|
import eu.darken.capod.common.navigation.Nav
|
||||||
import eu.darken.capod.common.navigation.NavigationController
|
import eu.darken.capod.common.navigation.NavigationController
|
||||||
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
|
|||||||
@Inject lateinit var popUpWindow: PopUpWindow
|
@Inject lateinit var popUpWindow: PopUpWindow
|
||||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||||
|
|
||||||
|
// Buffers warm-start intents (onNewIntent) so they are consumed from inside the Compose tree,
|
||||||
|
// after navCtrl.setup(backStack) has run. Calling navCtrl directly from onNewIntent races with
|
||||||
|
// the asynchronous Compose lambda and crashes if the back stack is not yet registered.
|
||||||
|
private val warmIntents = SingleEventFlow<Intent>()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
|
|||||||
navCtrl.setup(backStack)
|
navCtrl.setup(backStack)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
// Cold-start intent (the activity's launching intent).
|
||||||
consumeUpgradeExtra(intent)
|
consumeUpgradeExtra(intent)
|
||||||
|
// Warm-start intents delivered via onNewIntent. Consuming them here (instead of
|
||||||
|
// directly from onNewIntent) guarantees navCtrl.setup() has run.
|
||||||
|
warmIntents.collect { newIntent -> consumeUpgradeExtra(newIntent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
CapodTheme(state = themeState) {
|
CapodTheme(state = themeState) {
|
||||||
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
consumeUpgradeExtra(intent)
|
setIntent(intent)
|
||||||
|
// Defer to the Compose-side collector — navCtrl may not yet be set up here.
|
||||||
|
warmIntents.tryEmit(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun consumeUpgradeExtra(intent: Intent?) {
|
private fun consumeUpgradeExtra(intent: Intent?) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package eu.darken.capod.main.ui.components
|
package eu.darken.capod.main.ui.components
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.twotone.AutoAwesome
|
import androidx.compose.material.icons.twotone.AutoAwesome
|
||||||
@@ -28,3 +29,11 @@ fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) {
|
|||||||
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
|
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DrawableRes
|
||||||
|
fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
|
||||||
|
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
|
||||||
|
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
|
||||||
|
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
|
||||||
|
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
|||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
|
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||||
|
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -148,6 +149,8 @@ fun DeviceSettingsScreenHost(
|
|||||||
onNavigateUp = { vm.navUp() },
|
onNavigateUp = { vm.navUp() },
|
||||||
onAncModeChange = { vm.setAncMode(it) },
|
onAncModeChange = { vm.setAncMode(it) },
|
||||||
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
|
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
|
||||||
|
onConversationActionChange = { vm.setConversationAction(it) },
|
||||||
|
onConversationVolumeReductionChange = { vm.setConversationVolumeReduction(it) },
|
||||||
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
|
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
|
||||||
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
|
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
|
||||||
onToneVolumeChange = { vm.setToneVolume(it) },
|
onToneVolumeChange = { vm.setToneVolume(it) },
|
||||||
@@ -167,11 +170,11 @@ fun DeviceSettingsScreenHost(
|
|||||||
onOnePodModeChange = { vm.setOnePodMode(it) },
|
onOnePodModeChange = { vm.setOnePodMode(it) },
|
||||||
onAutoPlayChange = { vm.setAutoPlay(it) },
|
onAutoPlayChange = { vm.setAutoPlay(it) },
|
||||||
onAutoPauseChange = { vm.setAutoPause(it) },
|
onAutoPauseChange = { vm.setAutoPause(it) },
|
||||||
|
onStartMusicOnWearChange = { vm.setStartMusicOnWear(it) },
|
||||||
onAutoConnectChange = { vm.setAutoConnect(it) },
|
onAutoConnectChange = { vm.setAutoConnect(it) },
|
||||||
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
|
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
|
||||||
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
|
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
|
||||||
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
|
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
|
||||||
onFixMonitorMode = { vm.setMonitorModeAutomatic() },
|
|
||||||
onOpenIssueTracker = { vm.openIssueTracker() },
|
onOpenIssueTracker = { vm.openIssueTracker() },
|
||||||
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
|
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
|
||||||
)
|
)
|
||||||
@@ -186,6 +189,8 @@ fun DeviceSettingsScreen(
|
|||||||
onNavigateUp: () -> Unit,
|
onNavigateUp: () -> Unit,
|
||||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
|
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
|
||||||
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
||||||
|
onConversationActionChange: (ConversationAction) -> Unit = {},
|
||||||
|
onConversationVolumeReductionChange: (Int) -> Unit = {},
|
||||||
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
|
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
|
||||||
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
|
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
|
||||||
onToneVolumeChange: (Int) -> Unit = {},
|
onToneVolumeChange: (Int) -> Unit = {},
|
||||||
@@ -205,11 +210,11 @@ fun DeviceSettingsScreen(
|
|||||||
onOnePodModeChange: (Boolean) -> Unit = {},
|
onOnePodModeChange: (Boolean) -> Unit = {},
|
||||||
onAutoPlayChange: (Boolean) -> Unit = {},
|
onAutoPlayChange: (Boolean) -> Unit = {},
|
||||||
onAutoPauseChange: (Boolean) -> Unit = {},
|
onAutoPauseChange: (Boolean) -> Unit = {},
|
||||||
|
onStartMusicOnWearChange: (Boolean) -> Unit = {},
|
||||||
onAutoConnectChange: (Boolean) -> Unit = {},
|
onAutoConnectChange: (Boolean) -> Unit = {},
|
||||||
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
||||||
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
||||||
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
||||||
onFixMonitorMode: () -> Unit = {},
|
|
||||||
onOpenIssueTracker: () -> Unit = {},
|
onOpenIssueTracker: () -> Unit = {},
|
||||||
onOpenAapTracker: () -> Unit = {},
|
onOpenAapTracker: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@@ -339,17 +344,18 @@ fun DeviceSettingsScreen(
|
|||||||
device = device,
|
device = device,
|
||||||
features = features,
|
features = features,
|
||||||
isPro = isPro,
|
isPro = isPro,
|
||||||
monitorMode = state.monitorMode,
|
|
||||||
onAutoPlayChange = onAutoPlayChange,
|
onAutoPlayChange = onAutoPlayChange,
|
||||||
onAutoPauseChange = onAutoPauseChange,
|
onAutoPauseChange = onAutoPauseChange,
|
||||||
|
onStartMusicOnWearChange = onStartMusicOnWearChange,
|
||||||
onOnePodModeChange = onOnePodModeChange,
|
onOnePodModeChange = onOnePodModeChange,
|
||||||
onConversationalAwarenessChange = onConversationalAwarenessChange,
|
onConversationalAwarenessChange = onConversationalAwarenessChange,
|
||||||
|
onConversationActionChange = onConversationActionChange,
|
||||||
|
onConversationVolumeReductionChange = onConversationVolumeReductionChange,
|
||||||
onSleepDetectionChange = onSleepDetectionChange,
|
onSleepDetectionChange = onSleepDetectionChange,
|
||||||
onAutoConnectChange = onAutoConnectChange,
|
onAutoConnectChange = onAutoConnectChange,
|
||||||
onAutoConnectConditionChange = onAutoConnectConditionChange,
|
onAutoConnectConditionChange = onAutoConnectConditionChange,
|
||||||
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
|
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
|
||||||
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
|
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
|
||||||
onFixMonitorMode = onFixMonitorMode,
|
|
||||||
onOpenIssueTracker = onOpenIssueTracker,
|
onOpenIssueTracker = onOpenIssueTracker,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -386,7 +392,7 @@ fun DeviceSettingsScreen(
|
|||||||
val showSoundSection =
|
val showSoundSection =
|
||||||
(features.hasPersonalizedVolume && personalizedVol != null) ||
|
(features.hasPersonalizedVolume && personalizedVol != null) ||
|
||||||
(features.hasToneVolume && toneVol != null) ||
|
(features.hasToneVolume && toneVol != null) ||
|
||||||
features.hasMicrophoneMode
|
(features.hasMicrophoneMode && device.microphoneMode != null)
|
||||||
if (showSoundSection) {
|
if (showSoundSection) {
|
||||||
item("sound_section") {
|
item("sound_section") {
|
||||||
SoundCard(
|
SoundCard(
|
||||||
@@ -404,7 +410,7 @@ fun DeviceSettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Controls ─────────────────────────────────
|
// ── Controls ─────────────────────────────────
|
||||||
val showControlsSection = features.hasStemConfig ||
|
val showControlsSection = (features.hasStemConfig && device.stemConfig != null) ||
|
||||||
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
|
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
|
||||||
(features.hasPressSpeed && device.pressSpeed != null) ||
|
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||||
|
|||||||
+41
-22
@@ -5,8 +5,10 @@ import eu.darken.capod.common.SystemTimeSource
|
|||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
import eu.darken.capod.common.WebpageTool
|
import eu.darken.capod.common.WebpageTool
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
|
import eu.darken.capod.common.bluetooth.NudgeAttemptResult
|
||||||
|
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||||
|
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
import eu.darken.capod.common.datastore.value
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
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.log
|
||||||
@@ -16,9 +18,9 @@ import eu.darken.capod.common.navigation.Nav
|
|||||||
import eu.darken.capod.common.uix.ViewModel4
|
import eu.darken.capod.common.uix.ViewModel4
|
||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.common.upgrade.isPro
|
import eu.darken.capod.common.upgrade.isPro
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
|
||||||
import eu.darken.capod.main.core.MonitorMode
|
import eu.darken.capod.main.core.MonitorMode
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.resolvedAncCycleMask
|
import eu.darken.capod.monitor.core.resolvedAncCycleMask
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
@@ -29,6 +31,7 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
|||||||
import eu.darken.capod.profiles.core.ProfileId
|
import eu.darken.capod.profiles.core.ProfileId
|
||||||
import eu.darken.capod.profiles.core.ReactionConfig
|
import eu.darken.capod.profiles.core.ReactionConfig
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||||
|
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||||
import eu.darken.capod.reaction.core.stem.StemAction
|
import eu.darken.capod.reaction.core.stem.StemAction
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -51,7 +54,8 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
private val upgradeRepo: UpgradeRepo,
|
private val upgradeRepo: UpgradeRepo,
|
||||||
private val bluetoothManager: BluetoothManager2,
|
private val bluetoothManager: BluetoothManager2,
|
||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
private val generalSettings: GeneralSettings,
|
private val monitorModeResolver: MonitorModeResolver,
|
||||||
|
private val nudgeCapabilityStore: NudgeCapabilityStore,
|
||||||
private val timeSource: TimeSource,
|
private val timeSource: TimeSource,
|
||||||
private val webpageTool: WebpageTool,
|
private val webpageTool: WebpageTool,
|
||||||
) : ViewModel4(dispatcherProvider) {
|
) : ViewModel4(dispatcherProvider) {
|
||||||
@@ -114,8 +118,9 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
// The Bluetooth HEADSET profile lookup can be slow to produce its first value.
|
// The Bluetooth HEADSET profile lookup can be slow to produce its first value.
|
||||||
// Seed this branch so the screen can render immediately after navigation.
|
// Seed this branch so the screen can render immediately after navigation.
|
||||||
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
|
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
|
||||||
generalSettings.monitorMode.flow,
|
monitorModeResolver.effectiveMode,
|
||||||
profilesRepo.profiles,
|
profilesRepo.profiles,
|
||||||
|
nudgeCapabilityStore.availability,
|
||||||
) { args ->
|
) { args ->
|
||||||
val device = args[1] as PodDevice?
|
val device = args[1] as PodDevice?
|
||||||
val upgrade = args[2] as UpgradeRepo.Info
|
val upgrade = args[2] as UpgradeRepo.Info
|
||||||
@@ -127,6 +132,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
|
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
|
||||||
|
val nudgeAvailability = args[7] as NudgeAvailability
|
||||||
val stemActions = profiles.filterIsInstance<AppleDeviceProfile>()
|
val stemActions = profiles.filterIsInstance<AppleDeviceProfile>()
|
||||||
.firstOrNull { it.id == profileId }
|
.firstOrNull { it.id == profileId }
|
||||||
?.stemActions
|
?.stemActions
|
||||||
@@ -142,7 +148,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
device = device,
|
device = device,
|
||||||
now = timeSource.now(),
|
now = timeSource.now(),
|
||||||
isPro = upgrade.isPro,
|
isPro = upgrade.isPro,
|
||||||
isNudgeAvailable = bluetoothManager.isNudgeAvailable,
|
isNudgeAvailable = nudgeAvailability != NudgeAvailability.BROKEN,
|
||||||
isForceConnecting = forcing,
|
isForceConnecting = forcing,
|
||||||
isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true,
|
isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true,
|
||||||
monitorMode = monitorMode,
|
monitorMode = monitorMode,
|
||||||
@@ -206,18 +212,19 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
events.tryEmit(Event.OpenBluetoothSettings)
|
events.tryEmit(Event.OpenBluetoothSettings)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
if (!bluetoothManager.isNudgeAvailable) {
|
if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
|
||||||
events.tryEmit(Event.OpenBluetoothSettings)
|
events.tryEmit(Event.OpenBluetoothSettings)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val accepted = try {
|
val result = try {
|
||||||
bluetoothManager.nudgeConnection(bonded)
|
bluetoothManager.nudgeConnection(bonded)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
|
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
|
||||||
false
|
NudgeAttemptResult.Rejected
|
||||||
}
|
}
|
||||||
log(TAG, INFO) { "nudgeConnection($bonded) accepted=$accepted" }
|
log(TAG, INFO) { "nudgeConnection($bonded) result=$result" }
|
||||||
if (!accepted) {
|
nudgeCapabilityStore.record(result)
|
||||||
|
if (result != NudgeAttemptResult.Accepted) {
|
||||||
events.tryEmit(Event.OpenBluetoothSettings)
|
events.tryEmit(Event.OpenBluetoothSettings)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -382,6 +389,15 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
syncEarDetection(autoPause = enabled)
|
syncEarDetection(autoPause = enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setStartMusicOnWear(enabled: Boolean) = launch {
|
||||||
|
log(TAG, INFO) { "setStartMusicOnWear($enabled)" }
|
||||||
|
if (enabled && !upgradeRepo.isPro()) {
|
||||||
|
navTo(Nav.Main.Upgrade)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
updateProfileNow { it.copy(startMusicOnWear = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps the device-side Automatic Ear Detection setting in sync with the
|
* Keeps the device-side Automatic Ear Detection setting in sync with the
|
||||||
* auto-play / auto-pause reaction toggles. When either reaction is active
|
* auto-play / auto-pause reaction toggles. When either reaction is active
|
||||||
@@ -405,15 +421,6 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
fun setAutoConnect(enabled: Boolean) = launch {
|
fun setAutoConnect(enabled: Boolean) = launch {
|
||||||
log(TAG, INFO) { "setAutoConnect($enabled)" }
|
log(TAG, INFO) { "setAutoConnect($enabled)" }
|
||||||
updateProfileNow { it.copy(autoConnect = enabled) }
|
updateProfileNow { it.copy(autoConnect = enabled) }
|
||||||
if (enabled) {
|
|
||||||
// Auto-connect requires ALWAYS mode to react to BLE/case/ear events when
|
|
||||||
// nothing is connected. We intentionally do NOT revert on disable — the user
|
|
||||||
// may have set ALWAYS manually, or another profile may still need it.
|
|
||||||
if (generalSettings.monitorMode.value() != MonitorMode.ALWAYS) {
|
|
||||||
log(TAG, INFO) { "Forcing monitorMode to ALWAYS because autoConnect was enabled" }
|
|
||||||
generalSettings.monitorMode.value(MonitorMode.ALWAYS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAutoConnectCondition(condition: AutoConnectCondition) = launch {
|
fun setAutoConnectCondition(condition: AutoConnectCondition) = launch {
|
||||||
@@ -431,11 +438,23 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
|
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMonitorModeAutomatic() = launch {
|
fun setConversationAction(action: ConversationAction) = launch {
|
||||||
log(TAG, INFO) { "setMonitorModeAutomatic()" }
|
log(TAG, INFO) { "setConversationAction($action)" }
|
||||||
generalSettings.monitorMode.value(MonitorMode.AUTOMATIC)
|
// The action picker is only shown while Conversation Awareness is already enabled (the pod
|
||||||
|
// emits no speaking frames otherwise), so no need to auto-enable it here.
|
||||||
|
if (action != ConversationAction.NOTHING && !upgradeRepo.isPro()) {
|
||||||
|
navTo(Nav.Main.Upgrade)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
updateProfileNow { it.copy(conversationAction = action) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setConversationVolumeReduction(percent: Int) = launch {
|
||||||
|
log(TAG, INFO) { "setConversationVolumeReduction($percent)" }
|
||||||
|
updateProfileNow { it.copy(conversationVolumeReduction = percent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun navToPressControls() = launch {
|
fun navToPressControls() = launch {
|
||||||
log(TAG, INFO) { "navToPressControls()" }
|
log(TAG, INFO) { "navToPressControls()" }
|
||||||
val profileId = targetProfileId.value ?: return@launch
|
val profileId = targetProfileId.value ?: return@launch
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ internal fun ControlsCard(
|
|||||||
val volSwipe = device.volumeSwipe
|
val volSwipe = device.volumeSwipe
|
||||||
val volSwipeLen = device.volumeSwipeLength
|
val volSwipeLen = device.volumeSwipeLength
|
||||||
|
|
||||||
val showPressControlsNav = features.hasStemConfig ||
|
val showPressControlsNav = (features.hasStemConfig && device.stemConfig != null) ||
|
||||||
features.hasPressSpeed ||
|
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||||
features.hasPressHoldDuration ||
|
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||||
features.hasEndCallMuteMic
|
(features.hasEndCallMuteMic && device.endCallMuteMic != null)
|
||||||
|
|
||||||
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
|
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
|
||||||
if (showPressControlsNav) {
|
if (showPressControlsNav) {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import android.os.Build
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||||
|
import androidx.compose.material.icons.automirrored.twotone.VolumeDown
|
||||||
import androidx.compose.material.icons.twotone.BluetoothConnected
|
import androidx.compose.material.icons.twotone.BluetoothConnected
|
||||||
import androidx.compose.material.icons.twotone.Hearing
|
import androidx.compose.material.icons.twotone.Hearing
|
||||||
import androidx.compose.material.icons.twotone.LooksOne
|
import androidx.compose.material.icons.twotone.LooksOne
|
||||||
import androidx.compose.material.icons.twotone.Nightlight
|
import androidx.compose.material.icons.twotone.Nightlight
|
||||||
import androidx.compose.material.icons.twotone.PauseCircle
|
import androidx.compose.material.icons.twotone.PauseCircle
|
||||||
import androidx.compose.material.icons.twotone.PlayCircle
|
import androidx.compose.material.icons.twotone.PlayCircle
|
||||||
|
import androidx.compose.material.icons.twotone.RecordVoiceOver
|
||||||
import androidx.compose.material.icons.twotone.Workspaces
|
import androidx.compose.material.icons.twotone.Workspaces
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -29,39 +31,45 @@ import eu.darken.capod.common.compose.Preview2
|
|||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.settings.InfoBoxType
|
import eu.darken.capod.common.settings.InfoBoxType
|
||||||
import eu.darken.capod.common.settings.SettingsBaseItem
|
import eu.darken.capod.common.settings.SettingsBaseItem
|
||||||
|
import eu.darken.capod.common.settings.SettingsCategoryHeader
|
||||||
import eu.darken.capod.common.settings.SettingsInfoBox
|
import eu.darken.capod.common.settings.SettingsInfoBox
|
||||||
import eu.darken.capod.common.settings.SettingsSection
|
import eu.darken.capod.common.settings.SettingsSection
|
||||||
|
import eu.darken.capod.common.settings.SettingsSliderItem
|
||||||
import eu.darken.capod.common.settings.SettingsSwitchItem
|
import eu.darken.capod.common.settings.SettingsSwitchItem
|
||||||
import eu.darken.capod.main.core.MonitorMode
|
|
||||||
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
|
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
|
||||||
|
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
|
||||||
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import eu.darken.capod.profiles.core.ReactionConfig
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||||
|
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ReactionsCard(
|
internal fun ReactionsCard(
|
||||||
device: PodDevice,
|
device: PodDevice,
|
||||||
features: PodModel.Features,
|
features: PodModel.Features,
|
||||||
isPro: Boolean,
|
isPro: Boolean,
|
||||||
monitorMode: MonitorMode,
|
|
||||||
onAutoPlayChange: (Boolean) -> Unit = {},
|
onAutoPlayChange: (Boolean) -> Unit = {},
|
||||||
onAutoPauseChange: (Boolean) -> Unit = {},
|
onAutoPauseChange: (Boolean) -> Unit = {},
|
||||||
|
onStartMusicOnWearChange: (Boolean) -> Unit = {},
|
||||||
onOnePodModeChange: (Boolean) -> Unit = {},
|
onOnePodModeChange: (Boolean) -> Unit = {},
|
||||||
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
||||||
|
onConversationActionChange: (ConversationAction) -> Unit = {},
|
||||||
|
onConversationVolumeReductionChange: (Int) -> Unit = {},
|
||||||
onSleepDetectionChange: (Boolean) -> Unit = {},
|
onSleepDetectionChange: (Boolean) -> Unit = {},
|
||||||
onAutoConnectChange: (Boolean) -> Unit = {},
|
onAutoConnectChange: (Boolean) -> Unit = {},
|
||||||
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
||||||
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
||||||
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
||||||
onFixMonitorMode: () -> Unit = {},
|
|
||||||
onOpenIssueTracker: () -> Unit = {},
|
onOpenIssueTracker: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val reactions = device.reactions
|
val reactions = device.reactions
|
||||||
val enabled = device.isAapReady
|
val enabled = device.isAapReady
|
||||||
|
|
||||||
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
|
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
|
||||||
|
var showConversationActionDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
|
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
|
||||||
if (features.hasEarDetection) {
|
if (features.hasEarDetection) {
|
||||||
@@ -73,6 +81,16 @@ internal fun ReactionsCard(
|
|||||||
onCheckedChange = onAutoPlayChange,
|
onCheckedChange = onAutoPlayChange,
|
||||||
requiresUpgrade = !isPro,
|
requiresUpgrade = !isPro,
|
||||||
)
|
)
|
||||||
|
if (reactions.autoPlay) {
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon = Icons.TwoTone.PlayCircle,
|
||||||
|
title = stringResource(R.string.settings_start_music_on_wear_label),
|
||||||
|
subtitle = stringResource(R.string.settings_start_music_on_wear_description),
|
||||||
|
checked = reactions.startMusicOnWear,
|
||||||
|
onCheckedChange = onStartMusicOnWearChange,
|
||||||
|
requiresUpgrade = !isPro,
|
||||||
|
)
|
||||||
|
}
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon = Icons.TwoTone.PauseCircle,
|
icon = Icons.TwoTone.PauseCircle,
|
||||||
title = stringResource(R.string.settings_autopause_label),
|
title = stringResource(R.string.settings_autopause_label),
|
||||||
@@ -117,20 +135,45 @@ internal fun ReactionsCard(
|
|||||||
}
|
}
|
||||||
if (device.isAapConnected) {
|
if (device.isAapConnected) {
|
||||||
val convAwareness = device.conversationalAwareness
|
val convAwareness = device.conversationalAwareness
|
||||||
val hasAnyAapReaction =
|
val showConversationAwareness = features.hasConversationAwareness && convAwareness != null
|
||||||
(features.hasConversationAwareness && convAwareness != null) ||
|
val hasAnyAapReaction = showConversationAwareness || features.hasSleepDetection
|
||||||
features.hasSleepDetection
|
if (showConversationAwareness) {
|
||||||
if (features.hasConversationAwareness && convAwareness != null) {
|
SettingsCategoryHeader(text = stringResource(R.string.conversation_awareness_label))
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon = Icons.TwoTone.Hearing,
|
icon = Icons.TwoTone.Hearing,
|
||||||
title = stringResource(R.string.conversation_awareness_label),
|
title = stringResource(R.string.conversation_awareness_label),
|
||||||
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
|
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
|
||||||
checked = convAwareness.enabled,
|
checked = convAwareness?.enabled == true,
|
||||||
onCheckedChange = onConversationalAwarenessChange,
|
onCheckedChange = onConversationalAwarenessChange,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
)
|
)
|
||||||
|
// The "when you start speaking" reaction only fires while CA is on (the pod emits no
|
||||||
|
// speaking frames otherwise), so only surface it once CA is actually enabled.
|
||||||
|
if (convAwareness?.enabled == true) {
|
||||||
|
SettingsBaseItem(
|
||||||
|
icon = Icons.TwoTone.RecordVoiceOver,
|
||||||
|
title = stringResource(R.string.settings_conversation_action_label),
|
||||||
|
subtitle = stringResource(reactions.conversationAction.labelRes),
|
||||||
|
onClick = { showConversationActionDialog = true },
|
||||||
|
enabled = enabled,
|
||||||
|
requiresUpgrade = !isPro,
|
||||||
|
)
|
||||||
|
if (reactions.conversationAction == ConversationAction.LOWER_VOLUME) {
|
||||||
|
SettingsSliderItem(
|
||||||
|
icon = Icons.AutoMirrored.TwoTone.VolumeDown,
|
||||||
|
title = stringResource(R.string.settings_conversation_volume_reduction_label),
|
||||||
|
value = reactions.conversationVolumeReduction.toFloat(),
|
||||||
|
onValueChange = { onConversationVolumeReductionChange(it.toInt()) },
|
||||||
|
valueRange = ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION.toFloat()..
|
||||||
|
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION.toFloat(),
|
||||||
|
enabled = enabled,
|
||||||
|
valueLabel = { "${it.toInt()}%" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (features.hasSleepDetection) {
|
if (features.hasSleepDetection) {
|
||||||
|
if (showConversationAwareness) ReactionsDivider()
|
||||||
val sleepDet = device.sleepDetection
|
val sleepDet = device.sleepDetection
|
||||||
?: AapSetting.SleepDetection(enabled = true)
|
?: AapSetting.SleepDetection(enabled = true)
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
@@ -198,23 +241,24 @@ internal fun ReactionsCard(
|
|||||||
requiresUpgrade = !isPro,
|
requiresUpgrade = !isPro,
|
||||||
)
|
)
|
||||||
val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection
|
val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection
|
||||||
if (anyPopupEnabled && monitorMode == MonitorMode.MANUAL) {
|
if (anyPopupEnabled) {
|
||||||
SettingsInfoBox(
|
|
||||||
text = stringResource(R.string.settings_popup_warning_manual_mode),
|
|
||||||
type = InfoBoxType.WARNING,
|
|
||||||
action = {
|
|
||||||
TextButton(onClick = onFixMonitorMode) {
|
|
||||||
Text(stringResource(R.string.general_fix_it_action))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else if (anyPopupEnabled) {
|
|
||||||
SettingsInfoBox(
|
SettingsInfoBox(
|
||||||
text = stringResource(R.string.settings_popup_info_not_in_app),
|
text = stringResource(R.string.settings_popup_info_not_in_app),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showConversationActionDialog) {
|
||||||
|
ConversationActionDialog(
|
||||||
|
current = reactions.conversationAction,
|
||||||
|
onSelect = {
|
||||||
|
onConversationActionChange(it)
|
||||||
|
showConversationActionDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showConversationActionDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showAutoConnectConditionDialog) {
|
if (showAutoConnectConditionDialog) {
|
||||||
AutoConnectConditionDialog(
|
AutoConnectConditionDialog(
|
||||||
current = reactions.autoConnectCondition,
|
current = reactions.autoConnectCondition,
|
||||||
@@ -246,7 +290,6 @@ private fun ReactionsCardPreview() = PreviewWrapper {
|
|||||||
device = device,
|
device = device,
|
||||||
features = device.model.features,
|
features = device.model.features,
|
||||||
isPro = state.isPro,
|
isPro = state.isPro,
|
||||||
monitorMode = state.monitorMode,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +302,5 @@ private fun ReactionsCardNonProPreview() = PreviewWrapper {
|
|||||||
device = device,
|
device = device,
|
||||||
features = device.model.features,
|
features = device.model.features,
|
||||||
isPro = state.isPro,
|
isPro = state.isPro,
|
||||||
monitorMode = state.monitorMode,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ internal fun SoundCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (features.hasMicrophoneMode) {
|
if (features.hasMicrophoneMode && device.microphoneMode != null) {
|
||||||
if (isPro) {
|
if (isPro) {
|
||||||
val micMode = device.microphoneMode
|
val micMode = device.microphoneMode
|
||||||
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
|
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
|
||||||
|
|||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
package eu.darken.capod.main.ui.devicesettings.dialogs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
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.semantics.Role
|
||||||
|
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.reaction.core.conversation.ConversationAction
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ConversationActionDialog(
|
||||||
|
current: ConversationAction,
|
||||||
|
onSelect: (ConversationAction) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(text = stringResource(R.string.settings_conversation_action_label)) },
|
||||||
|
text = {
|
||||||
|
Column(Modifier.selectableGroup()) {
|
||||||
|
ConversationAction.entries.forEach { action ->
|
||||||
|
val isSelected = action == current
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { onSelect(action) },
|
||||||
|
role = Role.RadioButton,
|
||||||
|
)
|
||||||
|
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(selected = isSelected, onClick = null)
|
||||||
|
Text(
|
||||||
|
text = stringResource(action.labelRes),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(text = stringResource(android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun ConversationActionDialogPreview() = PreviewWrapper {
|
||||||
|
ConversationActionDialog(
|
||||||
|
current = ConversationAction.LOWER_VOLUME,
|
||||||
|
onSelect = {},
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ 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.PermissionCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
|
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.SinglePodsCard
|
||||||
|
import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
@@ -172,6 +173,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
|||||||
},
|
},
|
||||||
onManageDevices = { vm.goToDeviceManager() },
|
onManageDevices = { vm.goToDeviceManager() },
|
||||||
onSettings = { vm.goToSettings() },
|
onSettings = { vm.goToSettings() },
|
||||||
|
onTroubleShooter = { vm.goToTroubleShooter() },
|
||||||
onUpgrade = { vm.onUpgrade() },
|
onUpgrade = { vm.onUpgrade() },
|
||||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||||
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||||
@@ -194,6 +196,7 @@ fun OverviewScreen(
|
|||||||
onBluetoothSettings: () -> Unit,
|
onBluetoothSettings: () -> Unit,
|
||||||
onManageDevices: () -> Unit,
|
onManageDevices: () -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
|
onTroubleShooter: () -> Unit = {},
|
||||||
onUpgrade: () -> Unit,
|
onUpgrade: () -> Unit,
|
||||||
onToggleUnmatched: () -> Unit,
|
onToggleUnmatched: () -> Unit,
|
||||||
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
|
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
|
||||||
@@ -327,7 +330,7 @@ fun OverviewScreen(
|
|||||||
PodDeviceCard(
|
PodDeviceCard(
|
||||||
device = device,
|
device = device,
|
||||||
isPro = state.upgradeInfo.isPro,
|
isPro = state.upgradeInfo.isPro,
|
||||||
showDebug = state.isDebugMode,
|
showDebug = state.isDebug,
|
||||||
now = state.now,
|
now = state.now,
|
||||||
isCollapsed = isCollapsed,
|
isCollapsed = isCollapsed,
|
||||||
onToggleCollapse = if (isToggleable) {
|
onToggleCollapse = if (isToggleable) {
|
||||||
@@ -351,18 +354,25 @@ fun OverviewScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4c. Troubleshooter suggestion — connected via audio but no live data is reaching us
|
||||||
|
if (state.showTroubleshootSuggestion) {
|
||||||
|
item(key = "troubleshoot_suggestion") {
|
||||||
|
TroubleshootSuggestionCard(onTroubleShooter = onTroubleShooter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Monitoring active card
|
// 5. Monitoring active card
|
||||||
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) {
|
if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) {
|
||||||
item(key = "monitoring_active") {
|
item(key = "monitoring_active") {
|
||||||
MonitoringActiveCard()
|
MonitoringActiveCard()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Unmatched devices section
|
// 6. Unmatched devices section
|
||||||
if (state.unmatchedDevices.isNotEmpty()) {
|
if (state.shouldShowUnmatchedSection) {
|
||||||
item(key = "unmatched_header") {
|
item(key = "unmatched_header") {
|
||||||
UnmatchedDevicesCard(
|
UnmatchedDevicesCard(
|
||||||
count = state.unmatchedDevices.size,
|
count = state.visibleUnmatchedDevices.size,
|
||||||
isExpanded = state.showUnmatchedDevices,
|
isExpanded = state.showUnmatchedDevices,
|
||||||
onToggle = onToggleUnmatched,
|
onToggle = onToggleUnmatched,
|
||||||
)
|
)
|
||||||
@@ -370,13 +380,13 @@ fun OverviewScreen(
|
|||||||
|
|
||||||
if (state.showUnmatchedDevices) {
|
if (state.showUnmatchedDevices) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = state.unmatchedDevices,
|
items = state.visibleUnmatchedDevices,
|
||||||
key = { index, device -> unmatchedDeviceKey(device, index) },
|
key = { index, device -> unmatchedDeviceKey(device, index) },
|
||||||
) { _, device ->
|
) { _, device ->
|
||||||
PodDeviceCard(
|
PodDeviceCard(
|
||||||
device = device,
|
device = device,
|
||||||
isPro = state.upgradeInfo.isPro,
|
isPro = state.upgradeInfo.isPro,
|
||||||
showDebug = state.isDebugMode,
|
showDebug = state.isDebug,
|
||||||
now = state.now,
|
now = state.now,
|
||||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||||
onUpgrade = onUpgrade,
|
onUpgrade = onUpgrade,
|
||||||
@@ -438,7 +448,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
|||||||
MockPodDataProvider.singlePodMonitored(),
|
MockPodDataProvider.singlePodMonitored(),
|
||||||
MockPodDataProvider.unknownMonitored(),
|
MockPodDataProvider.unknownMonitored(),
|
||||||
),
|
),
|
||||||
isDebugMode = false,
|
isDebug = false,
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = listOf(
|
profiles = listOf(
|
||||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||||
@@ -465,7 +475,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
|
|||||||
now = SystemTimeSource.now(),
|
now = SystemTimeSource.now(),
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = emptyList(),
|
devices = emptyList(),
|
||||||
isDebugMode = false,
|
isDebug = false,
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||||
@@ -488,7 +498,7 @@ private fun OverviewScreenNoProfilesPreview() = PreviewWrapper {
|
|||||||
now = SystemTimeSource.now(),
|
now = SystemTimeSource.now(),
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = emptyList(),
|
devices = emptyList(),
|
||||||
isDebugMode = false,
|
isDebug = false,
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = emptyList(),
|
profiles = emptyList(),
|
||||||
upgradeInfo = MockPodDataProvider.gplayInfo(),
|
upgradeInfo = MockPodDataProvider.gplayInfo(),
|
||||||
@@ -511,7 +521,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
|
|||||||
now = SystemTimeSource.now(),
|
now = SystemTimeSource.now(),
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = emptyList(),
|
devices = emptyList(),
|
||||||
isDebugMode = false,
|
isDebug = false,
|
||||||
isBluetoothEnabled = false,
|
isBluetoothEnabled = false,
|
||||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||||
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
|
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import eu.darken.capod.common.TimeSource
|
|||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
import eu.darken.capod.common.datastore.value
|
import eu.darken.capod.common.datastore.value
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
import eu.darken.capod.common.debug.Bugs
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
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.log
|
||||||
@@ -22,7 +21,9 @@ import eu.darken.capod.main.core.GeneralSettings
|
|||||||
import eu.darken.capod.main.core.MonitorMode
|
import eu.darken.capod.main.core.MonitorMode
|
||||||
import eu.darken.capod.main.core.PermissionTool
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.tierRank
|
||||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
@@ -33,12 +34,16 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.combine as combineFlows
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -49,11 +54,11 @@ class OverviewViewModel @Inject constructor(
|
|||||||
private val deviceMonitor: DeviceMonitor,
|
private val deviceMonitor: DeviceMonitor,
|
||||||
private val permissionTool: PermissionTool,
|
private val permissionTool: PermissionTool,
|
||||||
private val generalSettings: GeneralSettings,
|
private val generalSettings: GeneralSettings,
|
||||||
debugSettings: DebugSettings,
|
|
||||||
private val upgradeRepo: UpgradeRepo,
|
private val upgradeRepo: UpgradeRepo,
|
||||||
private val bluetoothManager: BluetoothManager2,
|
private val bluetoothManager: BluetoothManager2,
|
||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
private val aapManager: AapConnectionManager,
|
private val aapManager: AapConnectionManager,
|
||||||
|
private val monitorModeResolver: MonitorModeResolver,
|
||||||
private val timeSource: TimeSource,
|
private val timeSource: TimeSource,
|
||||||
) : ViewModel4(dispatcherProvider) {
|
) : ViewModel4(dispatcherProvider) {
|
||||||
|
|
||||||
@@ -76,29 +81,83 @@ class OverviewViewModel @Inject constructor(
|
|||||||
private val showUnmatchedDevices = MutableStateFlow(false)
|
private val showUnmatchedDevices = MutableStateFlow(false)
|
||||||
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
|
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
|
||||||
val workerAutolaunch = permissionTool.missingScanPermissions
|
private data class OverviewUiSettings(
|
||||||
.onEach { permissions ->
|
val reactionsHintDismissed: Boolean,
|
||||||
if (permissions.isNotEmpty()) {
|
val hideUnmatchedDevices: Boolean,
|
||||||
log(TAG) { "Missing scan permissions: $permissions" }
|
val showTroubleshootSuggestion: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a profile is connected to the system (audio) but CAPod has *no* live data for any
|
||||||
|
* device — the classic "broadcasts are being dropped by the ROM" symptom (e.g. some HyperOS
|
||||||
|
* phones, see #603). Surfaced as a hint pointing at the Troubleshooter, which can probe and
|
||||||
|
* persist a working compatibility combo. Debounced so it doesn't flash during the brief window
|
||||||
|
* between an audio connection and the first BLE broadcast. Suppressed whenever any pod is live,
|
||||||
|
* so it never claims "no data" while data is visibly arriving (incl. the #603 duplicate state).
|
||||||
|
*/
|
||||||
|
private val troubleshootSuggestion = combineFlows(
|
||||||
|
profilesRepo.profiles,
|
||||||
|
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
|
||||||
|
deviceMonitor.devices,
|
||||||
|
permissionTool.missingScanPermissions,
|
||||||
|
) { profiles, connectedDevices, devices, missingScanPermissions ->
|
||||||
|
val connectedAddresses = connectedDevices.mapTo(mutableSetOf()) { it.address }
|
||||||
|
val anyProfileConnected = profiles.any { it.address != null && it.address in connectedAddresses }
|
||||||
|
val anyLivePod = devices.any { it.isLive }
|
||||||
|
missingScanPermissions.isEmpty() && anyProfileConnected && !anyLivePod
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.flatMapLatest { conditionMet ->
|
||||||
|
if (conditionMet) flow {
|
||||||
|
delay(TROUBLESHOOT_SUGGESTION_DELAY_MS)
|
||||||
|
emit(true)
|
||||||
|
} else flowOf(false)
|
||||||
|
}
|
||||||
|
.onStart { emit(false) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
|
private val overviewUiSettings = combineFlows(
|
||||||
|
generalSettings.reactionsHintDismissed.flow,
|
||||||
|
generalSettings.hideUnmatchedDevices.flow,
|
||||||
|
troubleshootSuggestion,
|
||||||
|
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion ->
|
||||||
|
OverviewUiSettings(reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
// When the persistent "hide unmatched" setting is enabled, reset the in-session expand
|
||||||
|
// toggle so the section reappears collapsed if the user later disables the setting.
|
||||||
|
launch {
|
||||||
|
generalSettings.hideUnmatchedDevices.flow
|
||||||
|
.filter { it }
|
||||||
|
.collect { showUnmatchedDevices.value = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val workerAutolaunch = combine(
|
||||||
|
permissionTool.missingScanPermissions,
|
||||||
|
monitorModeResolver.effectiveMode,
|
||||||
|
// BluetoothManager2.connectedDevices is gated by a slow HEADSET-profile lookup. Without
|
||||||
|
// an onStart seed the combine wouldn't fire until that profile is bound, so ALWAYS mode
|
||||||
|
// would silently delay autolaunch until the first connected-device emission.
|
||||||
|
bluetoothManager.connectedDevices
|
||||||
|
.onStart { emit(emptyList()) }
|
||||||
|
.map { it.isNotEmpty() }
|
||||||
|
.distinctUntilChanged(),
|
||||||
|
) { missing, mode, anyConnected -> Triple(missing, mode, anyConnected) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach { (missing, mode, anyConnected) ->
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
log(TAG) { "Missing scan permissions: $missing" }
|
||||||
return@onEach
|
return@onEach
|
||||||
}
|
}
|
||||||
|
val shouldStart = when (mode) {
|
||||||
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
|
|
||||||
MonitorMode.MANUAL -> false
|
MonitorMode.MANUAL -> false
|
||||||
MonitorMode.AUTOMATIC -> {
|
MonitorMode.AUTOMATIC -> anyConnected
|
||||||
try {
|
|
||||||
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
|
|
||||||
devices?.isNotEmpty() == true
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
log(TAG) { "Can't check connected devices without BLUETOOTH_CONNECT: ${e.message}" }
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MonitorMode.ALWAYS -> true
|
MonitorMode.ALWAYS -> true
|
||||||
}
|
}
|
||||||
if (shouldStart) {
|
if (shouldStart) {
|
||||||
log(TAG) { "Starting monitor" }
|
log(TAG) { "Starting monitor (mode=$mode)" }
|
||||||
monitorControl.startMonitor()
|
monitorControl.startMonitor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,15 +184,15 @@ class OverviewViewModel @Inject constructor(
|
|||||||
updateTicker,
|
updateTicker,
|
||||||
permissionTool.missingPermissions,
|
permissionTool.missingPermissions,
|
||||||
pods,
|
pods,
|
||||||
debugSettings.isDebugModeEnabled.flow,
|
Bugs.isDebug,
|
||||||
bluetoothManager.isBluetoothEnabled,
|
bluetoothManager.isBluetoothEnabled,
|
||||||
profilesRepo.profiles,
|
profilesRepo.profiles,
|
||||||
upgradeRepo.upgradeInfo,
|
upgradeRepo.upgradeInfo,
|
||||||
showUnmatchedDevices,
|
showUnmatchedDevices,
|
||||||
userExpansionOverrides,
|
userExpansionOverrides,
|
||||||
generalSettings.reactionsHintDismissed.flow,
|
overviewUiSettings,
|
||||||
profilesRepo.hadLegacyReactionData,
|
profilesRepo.hadLegacyReactionData,
|
||||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
|
) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, uiSettings, hadLegacyReactionData ->
|
||||||
// Prune stale overrides (profiles that no longer exist)
|
// Prune stale overrides (profiles that no longer exist)
|
||||||
val currentProfileIds = profiles.map { it.id }.toSet()
|
val currentProfileIds = profiles.map { it.id }.toSet()
|
||||||
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
|
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
|
||||||
@@ -142,13 +201,15 @@ class OverviewViewModel @Inject constructor(
|
|||||||
now = timeSource.now(),
|
now = timeSource.now(),
|
||||||
permissions = permissions,
|
permissions = permissions,
|
||||||
devices = devices,
|
devices = devices,
|
||||||
isDebugMode = isDebugMode,
|
isDebug = isDebug,
|
||||||
isBluetoothEnabled = isBluetoothEnabled,
|
isBluetoothEnabled = isBluetoothEnabled,
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
upgradeInfo = upgradeInfo,
|
upgradeInfo = upgradeInfo,
|
||||||
showUnmatchedDevices = showUnmatched,
|
showUnmatchedDevices = showUnmatched,
|
||||||
userExpandedIds = prunedExpandedIds,
|
userExpandedIds = prunedExpandedIds,
|
||||||
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed,
|
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
|
||||||
|
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
|
||||||
|
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
|
||||||
)
|
)
|
||||||
}.asLiveState()
|
}.asLiveState()
|
||||||
|
|
||||||
@@ -158,13 +219,15 @@ class OverviewViewModel @Inject constructor(
|
|||||||
val now: Instant,
|
val now: Instant,
|
||||||
val permissions: Set<Permission>,
|
val permissions: Set<Permission>,
|
||||||
val devices: List<PodDevice>,
|
val devices: List<PodDevice>,
|
||||||
val isDebugMode: Boolean,
|
val isDebug: Boolean,
|
||||||
val isBluetoothEnabled: Boolean,
|
val isBluetoothEnabled: Boolean,
|
||||||
val profiles: List<DeviceProfile>,
|
val profiles: List<DeviceProfile>,
|
||||||
val upgradeInfo: UpgradeRepo.Info,
|
val upgradeInfo: UpgradeRepo.Info,
|
||||||
val showUnmatchedDevices: Boolean,
|
val showUnmatchedDevices: Boolean,
|
||||||
val userExpandedIds: Set<String> = emptySet(),
|
val userExpandedIds: Set<String> = emptySet(),
|
||||||
val showReactionsHint: Boolean = false,
|
val showReactionsHint: Boolean = false,
|
||||||
|
val hideUnmatchedDevices: Boolean = false,
|
||||||
|
val showTroubleshootSuggestion: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||||
|
|
||||||
@@ -175,7 +238,7 @@ class OverviewViewModel @Inject constructor(
|
|||||||
|
|
||||||
val profiledDevices: List<PodDevice> by lazy {
|
val profiledDevices: List<PodDevice> by lazy {
|
||||||
devices.filter { it.profileId != null }.sortedWith(
|
devices.filter { it.profileId != null }.sortedWith(
|
||||||
compareBy<PodDevice> { deviceTierRank(it) }
|
compareBy<PodDevice> { it.tierRank() }
|
||||||
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -185,6 +248,11 @@ class OverviewViewModel @Inject constructor(
|
|||||||
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
|
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
|
||||||
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
|
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
|
||||||
|
|
||||||
|
/** Unmatched devices actually shown on the dashboard (empty when the hide setting is on). */
|
||||||
|
val visibleUnmatchedDevices: List<PodDevice>
|
||||||
|
get() = if (hideUnmatchedDevices) emptyList() else unmatchedDevices
|
||||||
|
val shouldShowUnmatchedSection: Boolean get() = visibleUnmatchedDevices.isNotEmpty()
|
||||||
|
|
||||||
val bluetoothIconState: BluetoothIconState
|
val bluetoothIconState: BluetoothIconState
|
||||||
get() = when {
|
get() = when {
|
||||||
isScanBlocked -> BluetoothIconState.HIDDEN
|
isScanBlocked -> BluetoothIconState.HIDDEN
|
||||||
@@ -223,6 +291,11 @@ class OverviewViewModel @Inject constructor(
|
|||||||
navTo(Nav.Settings.Index)
|
navTo(Nav.Settings.Index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun goToTroubleShooter() {
|
||||||
|
log(TAG, INFO) { "goToTroubleShooter()" }
|
||||||
|
navTo(Nav.Main.TroubleShooter)
|
||||||
|
}
|
||||||
|
|
||||||
fun goToDeviceManager() {
|
fun goToDeviceManager() {
|
||||||
log(TAG, INFO) { "goToDeviceManager()" }
|
log(TAG, INFO) { "goToDeviceManager()" }
|
||||||
navTo(Nav.Main.DeviceManager)
|
navTo(Nav.Main.DeviceManager)
|
||||||
@@ -283,13 +356,14 @@ class OverviewViewModel @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val FREE_DEVICE_LIMIT = 1
|
private const val FREE_DEVICE_LIMIT = 1
|
||||||
private val TAG = logTag("Overview", "VM")
|
|
||||||
|
|
||||||
/** Connection tier rank for sorting: lower = higher priority. */
|
/**
|
||||||
internal fun deviceTierRank(device: PodDevice): Int = when {
|
* How long the "connected but no live data" condition must hold before the troubleshooter
|
||||||
device.isSystemConnected -> 0
|
* hint appears, so it doesn't flash during the gap between an audio connection and the first
|
||||||
device.isLive -> 1
|
* BLE broadcast.
|
||||||
else -> 2
|
*/
|
||||||
}
|
private const val TROUBLESHOOT_SUGGESTION_DELAY_MS = 15_000L
|
||||||
|
|
||||||
|
private val TAG = logTag("Overview", "VM")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,8 +67,6 @@ import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
|||||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -227,7 +225,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
) {
|
) {
|
||||||
PodGauge(
|
PodGauge(
|
||||||
iconRes = device.leftPodIcon,
|
iconRes = device.leftPodIcon,
|
||||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
batteryPercent = device.batteryLeft,
|
||||||
chargingState = device.leftPodChargingState
|
chargingState = device.leftPodChargingState
|
||||||
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||||
isInEar = device.isLeftInEar ?: false,
|
isInEar = device.isLeftInEar ?: false,
|
||||||
@@ -239,7 +237,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
|
|
||||||
PodGauge(
|
PodGauge(
|
||||||
iconRes = device.rightPodIcon,
|
iconRes = device.rightPodIcon,
|
||||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
batteryPercent = device.batteryRight,
|
||||||
chargingState = device.rightPodChargingState
|
chargingState = device.rightPodChargingState
|
||||||
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||||
isInEar = device.isRightInEar ?: false,
|
isInEar = device.isRightInEar ?: false,
|
||||||
@@ -360,7 +358,7 @@ private fun PodGauge(
|
|||||||
|
|
||||||
// Battery percentage
|
// Battery percentage
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
|
text = formatBatteryPercent(context, batteryPercent),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = if (batteryPercent >= 0f) {
|
color = if (batteryPercent >= 0f) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
@@ -412,7 +410,7 @@ private fun CaseRow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
BatteryCapsule(
|
BatteryCapsule(
|
||||||
percent = device.batteryCase.toBatteryFloat(),
|
percent = device.batteryCase,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(8.dp),
|
.height(8.dp),
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ import eu.darken.capod.monitor.core.PodDevice
|
|||||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
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.aap.protocol.AapSetting
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@@ -192,17 +194,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
|
val percent = device.batteryHeadset
|
||||||
|
val isKnown = isKnownBattery(percent)
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = clamped ?: 0f,
|
targetValue = batteryProgress(percent),
|
||||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||||
label = "gaugeProgress",
|
label = "gaugeProgress",
|
||||||
)
|
)
|
||||||
|
|
||||||
val ringColor = when {
|
val ringColor = when {
|
||||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||||
else -> MaterialTheme.colorScheme.error
|
else -> MaterialTheme.colorScheme.error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +240,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Progress ring
|
// Progress ring
|
||||||
if (clamped != null) {
|
if (isKnown) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { animatedProgress },
|
progress = { animatedProgress },
|
||||||
modifier = Modifier.size(88.dp),
|
modifier = Modifier.size(88.dp),
|
||||||
@@ -250,9 +253,9 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
|
|
||||||
// Battery text inside ring
|
// Battery text inside ring
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, device.batteryHeadset),
|
text = formatBatteryPercent(context, percent),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = if (device.batteryHeadset != null) {
|
color = if (isKnown) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package eu.darken.capod.main.ui.overview.cards
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.twotone.Troubleshoot
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.unit.dp
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.common.compose.Preview2
|
||||||
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TroubleshootSuggestionCard(onTroubleShooter: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.TwoTone.Troubleshoot,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.overview_troubleshoot_suggestion_label),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.overview_troubleshoot_suggestion_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onTroubleShooter,
|
||||||
|
modifier = Modifier.align(Alignment.End),
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.overview_troubleshoot_suggestion_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun TroubleshootSuggestionCardPreview() = PreviewWrapper {
|
||||||
|
TroubleshootSuggestionCard(onTroubleShooter = {})
|
||||||
|
}
|
||||||
+17
-15
@@ -33,17 +33,19 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CompactBatterySummary(
|
fun CompactBatterySummary(
|
||||||
device: PodDevice,
|
device: PodDevice,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hasAnyBattery = device.batteryLeft != null
|
val hasAnyBattery = isKnownBattery(device.batteryLeft)
|
||||||
|| device.batteryRight != null
|
|| isKnownBattery(device.batteryRight)
|
||||||
|| device.batteryHeadset != null
|
|| isKnownBattery(device.batteryHeadset)
|
||||||
|| device.batteryCase != null
|
|| isKnownBattery(device.batteryCase)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) {
|
|||||||
percent = device.batteryRight,
|
percent = device.batteryRight,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (device.hasCase && device.batteryCase != null) {
|
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
MiniCaseCluster(device = device)
|
MiniCaseCluster(device = device)
|
||||||
}
|
}
|
||||||
@@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) {
|
|||||||
iconRes = null,
|
iconRes = null,
|
||||||
percent = device.batteryHeadset,
|
percent = device.batteryHeadset,
|
||||||
)
|
)
|
||||||
if (device.hasCase && device.batteryCase != null) {
|
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
MiniCaseCluster(device = device)
|
MiniCaseCluster(device = device)
|
||||||
}
|
}
|
||||||
@@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MiniPodRing(
|
private fun MiniPodRing(
|
||||||
iconRes: Int?,
|
iconRes: Int?,
|
||||||
percent: Float?,
|
percent: Float,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val clamped = percent?.coerceIn(0f, 1f)
|
val isKnown = isKnownBattery(percent)
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = clamped ?: 0f,
|
targetValue = batteryProgress(percent),
|
||||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||||
label = "miniGaugeProgress",
|
label = "miniGaugeProgress",
|
||||||
)
|
)
|
||||||
|
|
||||||
val ringColor = when {
|
val ringColor = when {
|
||||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||||
else -> MaterialTheme.colorScheme.error
|
else -> MaterialTheme.colorScheme.error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +157,7 @@ private fun MiniPodRing(
|
|||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
strokeCap = StrokeCap.Round,
|
strokeCap = StrokeCap.Round,
|
||||||
)
|
)
|
||||||
if (clamped != null) {
|
if (isKnown) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { animatedProgress },
|
progress = { animatedProgress },
|
||||||
modifier = Modifier.size(28.dp),
|
modifier = Modifier.size(28.dp),
|
||||||
@@ -177,7 +179,7 @@ private fun MiniPodRing(
|
|||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, percent),
|
text = formatBatteryPercent(context, percent),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = if (percent != null) {
|
color = if (isKnown) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -204,7 +206,7 @@ private fun MiniCaseCluster(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
BatteryCapsule(
|
BatteryCapsule(
|
||||||
percent = device.batteryCase ?: -1f,
|
percent = device.batteryCase,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(36.dp)
|
.width(36.dp)
|
||||||
.height(6.dp),
|
.height(6.dp),
|
||||||
|
|||||||
+17
-101
@@ -1,44 +1,32 @@
|
|||||||
package eu.darken.capod.main.ui.settings.general
|
package eu.darken.capod.main.ui.settings.general
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.selection.selectable
|
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
||||||
import androidx.compose.material.icons.twotone.AccountTree
|
import androidx.compose.material.icons.twotone.AccountTree
|
||||||
import androidx.compose.material.icons.twotone.BugReport
|
|
||||||
import androidx.compose.material.icons.twotone.Contrast
|
import androidx.compose.material.icons.twotone.Contrast
|
||||||
import androidx.compose.material.icons.twotone.DarkMode
|
import androidx.compose.material.icons.twotone.DarkMode
|
||||||
import androidx.compose.material.icons.twotone.DisabledVisible
|
|
||||||
import androidx.compose.material.icons.twotone.FilterList
|
import androidx.compose.material.icons.twotone.FilterList
|
||||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||||
import androidx.compose.material.icons.twotone.Notifications
|
import androidx.compose.material.icons.twotone.Notifications
|
||||||
import androidx.compose.material.icons.twotone.Palette
|
import androidx.compose.material.icons.twotone.Palette
|
||||||
|
import androidx.compose.material.icons.twotone.VisibilityOff
|
||||||
import androidx.compose.material.icons.automirrored.twotone.ViewList
|
import androidx.compose.material.icons.automirrored.twotone.ViewList
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
@@ -55,7 +43,6 @@ import eu.darken.capod.common.theming.ThemeColor
|
|||||||
import eu.darken.capod.common.theming.ThemeMode
|
import eu.darken.capod.common.theming.ThemeMode
|
||||||
import eu.darken.capod.common.theming.ThemeState
|
import eu.darken.capod.common.theming.ThemeState
|
||||||
import eu.darken.capod.common.theming.ThemeStyle
|
import eu.darken.capod.common.theming.ThemeStyle
|
||||||
import eu.darken.capod.main.core.MonitorMode
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
||||||
@@ -67,13 +54,12 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
|||||||
GeneralSettingsScreen(
|
GeneralSettingsScreen(
|
||||||
state = it,
|
state = it,
|
||||||
onNavigateUp = { vm.navUp() },
|
onNavigateUp = { vm.navUp() },
|
||||||
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
|
|
||||||
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
|
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
|
||||||
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
|
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
|
||||||
onDebugSettings = { vm.goToDebugSettings() },
|
|
||||||
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
|
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
|
||||||
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
|
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
|
||||||
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
|
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
|
||||||
|
onHideUnmatchedDevicesChanged = { enabled -> vm.setHideUnmatchedDevices(enabled) },
|
||||||
onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
|
onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
|
||||||
onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
|
onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
|
||||||
onThemeColorSelected = { color -> vm.setThemeColor(color) },
|
onThemeColorSelected = { color -> vm.setThemeColor(color) },
|
||||||
@@ -86,20 +72,18 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
|||||||
fun GeneralSettingsScreen(
|
fun GeneralSettingsScreen(
|
||||||
state: GeneralSettingsViewModel.State,
|
state: GeneralSettingsViewModel.State,
|
||||||
onNavigateUp: () -> Unit,
|
onNavigateUp: () -> Unit,
|
||||||
onMonitorModeSelected: (MonitorMode) -> Unit,
|
|
||||||
onShowConnectedNotificationChanged: (Boolean) -> Unit,
|
onShowConnectedNotificationChanged: (Boolean) -> Unit,
|
||||||
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
|
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
|
||||||
onDebugSettings: () -> Unit,
|
|
||||||
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
|
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
|
||||||
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
|
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
|
||||||
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
|
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
|
||||||
|
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
|
||||||
onThemeModeSelected: (ThemeMode) -> Unit = {},
|
onThemeModeSelected: (ThemeMode) -> Unit = {},
|
||||||
onThemeStyleSelected: (ThemeStyle) -> Unit = {},
|
onThemeStyleSelected: (ThemeStyle) -> Unit = {},
|
||||||
onThemeColorSelected: (ThemeColor) -> Unit = {},
|
onThemeColorSelected: (ThemeColor) -> Unit = {},
|
||||||
onUpgrade: () -> Unit = {},
|
onUpgrade: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var showMonitorModeDialog by remember { mutableStateOf(false) }
|
|
||||||
var showColorDialog by remember { mutableStateOf(false) }
|
var showColorDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
|
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
|
||||||
@@ -122,17 +106,6 @@ fun GeneralSettingsScreen(
|
|||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
item {
|
|
||||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_monitoring_label))
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
SettingsBaseItem(
|
|
||||||
title = stringResource(R.string.settings_monitor_mode_label),
|
|
||||||
subtitle = stringResource(state.monitorMode.labelRes),
|
|
||||||
icon = Icons.TwoTone.DisabledVisible,
|
|
||||||
onClick = { showMonitorModeDialog = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
item {
|
||||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
|
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
|
||||||
}
|
}
|
||||||
@@ -229,10 +202,17 @@ fun GeneralSettingsScreen(
|
|||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
SettingsBaseItem(
|
SettingsBaseItem(
|
||||||
title = stringResource(R.string.settings_debug_label),
|
title = stringResource(R.string.settings_overview_hide_unmatched_label),
|
||||||
subtitle = stringResource(R.string.settings_debug_description),
|
subtitle = stringResource(R.string.settings_overview_hide_unmatched_description),
|
||||||
icon = Icons.TwoTone.BugReport,
|
icon = Icons.TwoTone.VisibilityOff,
|
||||||
onClick = onDebugSettings,
|
onClick = { onHideUnmatchedDevicesChanged(!state.hideUnmatchedDevices) },
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = state.hideUnmatchedDevices,
|
||||||
|
onCheckedChange = onHideUnmatchedDevicesChanged,
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -286,20 +266,6 @@ fun GeneralSettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showMonitorModeDialog) {
|
|
||||||
ListPreferenceDialog(
|
|
||||||
title = stringResource(R.string.settings_monitor_mode_label),
|
|
||||||
entries = MonitorMode.entries,
|
|
||||||
selectedEntry = state.monitorMode,
|
|
||||||
onEntrySelected = {
|
|
||||||
onMonitorModeSelected(it)
|
|
||||||
showMonitorModeDialog = false
|
|
||||||
},
|
|
||||||
entryLabel = { stringResource(it.labelRes) },
|
|
||||||
onDismiss = { showMonitorModeDialog = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showColorDialog) {
|
if (showColorDialog) {
|
||||||
ThemeColorSelectorDialog(
|
ThemeColorSelectorDialog(
|
||||||
selectedColor = state.themeState.color,
|
selectedColor = state.themeState.color,
|
||||||
@@ -314,12 +280,12 @@ fun GeneralSettingsScreen(
|
|||||||
|
|
||||||
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
|
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
|
||||||
isPro = isPro,
|
isPro = isPro,
|
||||||
monitorMode = MonitorMode.AUTOMATIC,
|
|
||||||
showConnectedNotification = true,
|
showConnectedNotification = true,
|
||||||
keepNotificationAfterDisconnect = false,
|
keepNotificationAfterDisconnect = false,
|
||||||
isOffloadedFilteringDisabled = false,
|
isOffloadedFilteringDisabled = false,
|
||||||
isOffloadedBatchingDisabled = false,
|
isOffloadedBatchingDisabled = false,
|
||||||
useIndirectScanResultCallback = false,
|
useIndirectScanResultCallback = false,
|
||||||
|
hideUnmatchedDevices = false,
|
||||||
themeState = ThemeState(),
|
themeState = ThemeState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -329,13 +295,12 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
|
|||||||
GeneralSettingsScreen(
|
GeneralSettingsScreen(
|
||||||
state = previewGeneralState(isPro = true),
|
state = previewGeneralState(isPro = true),
|
||||||
onNavigateUp = {},
|
onNavigateUp = {},
|
||||||
onMonitorModeSelected = {},
|
|
||||||
onShowConnectedNotificationChanged = {},
|
onShowConnectedNotificationChanged = {},
|
||||||
onKeepNotificationAfterDisconnectChanged = {},
|
onKeepNotificationAfterDisconnectChanged = {},
|
||||||
onDebugSettings = {},
|
|
||||||
onOffloadedFilteringDisabledChanged = {},
|
onOffloadedFilteringDisabledChanged = {},
|
||||||
onOffloadedBatchingDisabledChanged = {},
|
onOffloadedBatchingDisabledChanged = {},
|
||||||
onUseIndirectScanResultCallbackChanged = {},
|
onUseIndirectScanResultCallbackChanged = {},
|
||||||
|
onHideUnmatchedDevicesChanged = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,60 +310,11 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
|
|||||||
GeneralSettingsScreen(
|
GeneralSettingsScreen(
|
||||||
state = previewGeneralState(isPro = false),
|
state = previewGeneralState(isPro = false),
|
||||||
onNavigateUp = {},
|
onNavigateUp = {},
|
||||||
onMonitorModeSelected = {},
|
|
||||||
onShowConnectedNotificationChanged = {},
|
onShowConnectedNotificationChanged = {},
|
||||||
onKeepNotificationAfterDisconnectChanged = {},
|
onKeepNotificationAfterDisconnectChanged = {},
|
||||||
onDebugSettings = {},
|
|
||||||
onOffloadedFilteringDisabledChanged = {},
|
onOffloadedFilteringDisabledChanged = {},
|
||||||
onOffloadedBatchingDisabledChanged = {},
|
onOffloadedBatchingDisabledChanged = {},
|
||||||
onUseIndirectScanResultCallbackChanged = {},
|
onUseIndirectScanResultCallbackChanged = {},
|
||||||
)
|
onHideUnmatchedDevicesChanged = {},
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun <T> ListPreferenceDialog(
|
|
||||||
title: String,
|
|
||||||
entries: List<T>,
|
|
||||||
selectedEntry: T,
|
|
||||||
onEntrySelected: (T) -> Unit,
|
|
||||||
entryLabel: @Composable (T) -> String,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(text = title) },
|
|
||||||
text = {
|
|
||||||
Column(Modifier.selectableGroup()) {
|
|
||||||
entries.forEach { entry ->
|
|
||||||
val isSelected = entry == selectedEntry
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.selectable(
|
|
||||||
selected = isSelected,
|
|
||||||
onClick = { onEntrySelected(entry) },
|
|
||||||
role = Role.RadioButton,
|
|
||||||
)
|
|
||||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = isSelected,
|
|
||||||
onClick = null,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = entryLabel(entry),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(text = stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-19
@@ -13,7 +13,6 @@ import eu.darken.capod.common.theming.ThemeStyle
|
|||||||
import eu.darken.capod.common.uix.ViewModel4
|
import eu.darken.capod.common.uix.ViewModel4
|
||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
import eu.darken.capod.main.core.GeneralSettings
|
||||||
import eu.darken.capod.main.core.MonitorMode
|
|
||||||
import eu.darken.capod.main.core.themeState
|
import eu.darken.capod.main.core.themeState
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -30,12 +29,12 @@ class GeneralSettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
data class State(
|
data class State(
|
||||||
val isPro: Boolean,
|
val isPro: Boolean,
|
||||||
val monitorMode: MonitorMode,
|
|
||||||
val showConnectedNotification: Boolean,
|
val showConnectedNotification: Boolean,
|
||||||
val keepNotificationAfterDisconnect: Boolean,
|
val keepNotificationAfterDisconnect: Boolean,
|
||||||
val isOffloadedFilteringDisabled: Boolean,
|
val isOffloadedFilteringDisabled: Boolean,
|
||||||
val isOffloadedBatchingDisabled: Boolean,
|
val isOffloadedBatchingDisabled: Boolean,
|
||||||
val useIndirectScanResultCallback: Boolean,
|
val useIndirectScanResultCallback: Boolean,
|
||||||
|
val hideUnmatchedDevices: Boolean,
|
||||||
val themeState: ThemeState,
|
val themeState: ThemeState,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,12 +42,11 @@ class GeneralSettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
val state = combine(
|
val state = combine(
|
||||||
combine(
|
combine(
|
||||||
generalSettings.monitorMode.flow,
|
|
||||||
generalSettings.useExtraMonitorNotification.flow,
|
generalSettings.useExtraMonitorNotification.flow,
|
||||||
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
||||||
) { monitorMode, showNotif, keepNotif ->
|
) { showNotif, keepNotif ->
|
||||||
@Suppress("USELESS_CAST")
|
@Suppress("USELESS_CAST")
|
||||||
arrayOf<Any>(monitorMode as Any, showNotif as Any, keepNotif as Any)
|
arrayOf<Any>(showNotif as Any, keepNotif as Any)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||||
@@ -60,24 +58,20 @@ class GeneralSettingsViewModel @Inject constructor(
|
|||||||
},
|
},
|
||||||
generalSettings.themeState,
|
generalSettings.themeState,
|
||||||
isPro,
|
isPro,
|
||||||
) { general, compat, themeState, isPro ->
|
generalSettings.hideUnmatchedDevices.flow,
|
||||||
|
) { general, compat, themeState, isPro, hideUnmatched ->
|
||||||
State(
|
State(
|
||||||
isPro = isPro,
|
isPro = isPro,
|
||||||
monitorMode = general[0] as MonitorMode,
|
showConnectedNotification = general[0] as Boolean,
|
||||||
showConnectedNotification = general[1] as Boolean,
|
keepNotificationAfterDisconnect = general[1] as Boolean,
|
||||||
keepNotificationAfterDisconnect = general[2] as Boolean,
|
|
||||||
isOffloadedFilteringDisabled = compat[0] as Boolean,
|
isOffloadedFilteringDisabled = compat[0] as Boolean,
|
||||||
isOffloadedBatchingDisabled = compat[1] as Boolean,
|
isOffloadedBatchingDisabled = compat[1] as Boolean,
|
||||||
useIndirectScanResultCallback = compat[2] as Boolean,
|
useIndirectScanResultCallback = compat[2] as Boolean,
|
||||||
|
hideUnmatchedDevices = hideUnmatched,
|
||||||
themeState = themeState,
|
themeState = themeState,
|
||||||
)
|
)
|
||||||
}.asLiveState()
|
}.asLiveState()
|
||||||
|
|
||||||
fun setMonitorMode(mode: MonitorMode) {
|
|
||||||
log(TAG, INFO) { "setMonitorMode($mode)" }
|
|
||||||
generalSettings.monitorMode.valueBlocking = mode
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setShowConnectedNotification(enabled: Boolean) {
|
fun setShowConnectedNotification(enabled: Boolean) {
|
||||||
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
|
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
|
||||||
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
|
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
|
||||||
@@ -103,6 +97,11 @@ class GeneralSettingsViewModel @Inject constructor(
|
|||||||
generalSettings.useIndirectScanResultCallback.valueBlocking = enabled
|
generalSettings.useIndirectScanResultCallback.valueBlocking = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setHideUnmatchedDevices(enabled: Boolean) {
|
||||||
|
log(TAG, INFO) { "setHideUnmatchedDevices($enabled)" }
|
||||||
|
generalSettings.hideUnmatchedDevices.valueBlocking = enabled
|
||||||
|
}
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) = launch {
|
fun setThemeMode(mode: ThemeMode) = launch {
|
||||||
log(TAG, INFO) { "setThemeMode($mode)" }
|
log(TAG, INFO) { "setThemeMode($mode)" }
|
||||||
if (isPro.first()) {
|
if (isPro.first()) {
|
||||||
@@ -135,11 +134,6 @@ class GeneralSettingsViewModel @Inject constructor(
|
|||||||
navTo(Nav.Main.Upgrade)
|
navTo(Nav.Main.Upgrade)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToDebugSettings() {
|
|
||||||
log(TAG, INFO) { "goToDebugSettings()" }
|
|
||||||
navTo(Nav.Settings.Debug)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Settings", "General", "VM")
|
private val TAG = logTag("Settings", "General", "VM")
|
||||||
}
|
}
|
||||||
|
|||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
package eu.darken.capod.main.ui.settings.general.debug
|
|
||||||
|
|
||||||
import androidx.navigation3.runtime.EntryProviderScope
|
|
||||||
import androidx.navigation3.runtime.NavKey
|
|
||||||
import dagger.Binds
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import dagger.multibindings.IntoSet
|
|
||||||
import eu.darken.capod.common.navigation.Nav
|
|
||||||
import eu.darken.capod.common.navigation.NavigationEntry
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class DebugSettingsNavigation @Inject constructor() : NavigationEntry {
|
|
||||||
override fun EntryProviderScope<NavKey>.setup() {
|
|
||||||
entry<Nav.Settings.Debug> { DebugSettingsScreenHost() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
abstract class Mod {
|
|
||||||
@Binds
|
|
||||||
@IntoSet
|
|
||||||
abstract fun bind(entry: DebugSettingsNavigation): NavigationEntry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-136
@@ -1,136 +0,0 @@
|
|||||||
package eu.darken.capod.main.ui.settings.general.debug
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
|
||||||
import androidx.compose.material.icons.twotone.BugReport
|
|
||||||
import androidx.compose.material.icons.twotone.DataArray
|
|
||||||
import androidx.compose.material.icons.twotone.DevicesOther
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import eu.darken.capod.common.error.ErrorEventHandler
|
|
||||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
|
||||||
import eu.darken.capod.common.settings.SettingsBaseItem
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DebugSettingsScreenHost(vm: DebugSettingsViewModel = hiltViewModel()) {
|
|
||||||
ErrorEventHandler(vm)
|
|
||||||
NavigationEventHandler(vm)
|
|
||||||
|
|
||||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
|
||||||
state?.let {
|
|
||||||
DebugSettingsScreen(
|
|
||||||
state = it,
|
|
||||||
onNavigateUp = { vm.navUp() },
|
|
||||||
onDebugModeChanged = { enabled -> vm.setDebugModeEnabled(enabled) },
|
|
||||||
onShowFakeDataChanged = { enabled -> vm.setShowFakeData(enabled) },
|
|
||||||
onShowUnfilteredChanged = { enabled -> vm.setShowUnfiltered(enabled) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DebugSettingsScreen(
|
|
||||||
state: DebugSettingsViewModel.State,
|
|
||||||
onNavigateUp: () -> Unit,
|
|
||||||
onDebugModeChanged: (Boolean) -> Unit,
|
|
||||||
onShowFakeDataChanged: (Boolean) -> Unit,
|
|
||||||
onShowUnfilteredChanged: (Boolean) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(text = stringResource(R.string.settings_debug_label)) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onNavigateUp) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
|
||||||
item {
|
|
||||||
SettingsBaseItem(
|
|
||||||
title = stringResource(R.string.settings_debug_mode_label),
|
|
||||||
subtitle = stringResource(R.string.settings_debug_mode_description),
|
|
||||||
icon = Icons.TwoTone.BugReport,
|
|
||||||
onClick = { onDebugModeChanged(!state.isDebugModeEnabled) },
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = state.isDebugModeEnabled,
|
|
||||||
onCheckedChange = onDebugModeChanged,
|
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
SettingsBaseItem(
|
|
||||||
title = stringResource(R.string.settings_fake_data_label),
|
|
||||||
subtitle = stringResource(R.string.settings_fake_data_description),
|
|
||||||
icon = Icons.TwoTone.DataArray,
|
|
||||||
onClick = { onShowFakeDataChanged(!state.showFakeData) },
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = state.showFakeData,
|
|
||||||
onCheckedChange = onShowFakeDataChanged,
|
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
SettingsBaseItem(
|
|
||||||
title = stringResource(R.string.settings_blescanner_unfiltered_label),
|
|
||||||
subtitle = stringResource(R.string.settings_blescanner_unfiltered_description),
|
|
||||||
icon = Icons.TwoTone.DevicesOther,
|
|
||||||
onClick = { onShowUnfilteredChanged(!state.showUnfiltered) },
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = state.showUnfiltered,
|
|
||||||
onCheckedChange = onShowUnfilteredChanged,
|
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview2
|
|
||||||
@Composable
|
|
||||||
private fun DebugSettingsScreenPreview() = PreviewWrapper {
|
|
||||||
DebugSettingsScreen(
|
|
||||||
state = DebugSettingsViewModel.State(
|
|
||||||
isDebugModeEnabled = true,
|
|
||||||
showFakeData = false,
|
|
||||||
showUnfiltered = false,
|
|
||||||
),
|
|
||||||
onNavigateUp = {},
|
|
||||||
onDebugModeChanged = {},
|
|
||||||
onShowFakeDataChanged = {},
|
|
||||||
onShowUnfilteredChanged = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-57
@@ -1,57 +0,0 @@
|
|||||||
package eu.darken.capod.main.ui.settings.general.debug
|
|
||||||
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
|
||||||
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.uix.ViewModel4
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import javax.inject.Inject
|
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class DebugSettingsViewModel @Inject constructor(
|
|
||||||
dispatcherProvider: DispatcherProvider,
|
|
||||||
private val debugSettings: DebugSettings,
|
|
||||||
) : ViewModel4(dispatcherProvider) {
|
|
||||||
|
|
||||||
data class State(
|
|
||||||
val isDebugModeEnabled: Boolean,
|
|
||||||
val showFakeData: Boolean,
|
|
||||||
val showUnfiltered: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
val state = combine(
|
|
||||||
debugSettings.isDebugModeEnabled.flow,
|
|
||||||
debugSettings.showFakeData.flow,
|
|
||||||
debugSettings.showUnfiltered.flow,
|
|
||||||
) { debugMode, fakeData, unfiltered ->
|
|
||||||
State(
|
|
||||||
isDebugModeEnabled = debugMode,
|
|
||||||
showFakeData = fakeData,
|
|
||||||
showUnfiltered = unfiltered,
|
|
||||||
)
|
|
||||||
}.asLiveState()
|
|
||||||
|
|
||||||
fun setDebugModeEnabled(enabled: Boolean) {
|
|
||||||
log(TAG, INFO) { "setDebugModeEnabled($enabled)" }
|
|
||||||
debugSettings.isDebugModeEnabled.valueBlocking = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setShowFakeData(enabled: Boolean) {
|
|
||||||
log(TAG, INFO) { "setShowFakeData($enabled)" }
|
|
||||||
debugSettings.showFakeData.valueBlocking = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setShowUnfiltered(enabled: Boolean) {
|
|
||||||
log(TAG, INFO) { "setShowUnfiltered($enabled)" }
|
|
||||||
debugSettings.showUnfiltered.valueBlocking = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = logTag("Settings", "Debug", "VM")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package eu.darken.capod.main.ui.tile
|
||||||
|
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
|
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.asLog
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-scoped trailing-edge debouncer for tile-driven `SetAncMode` commands.
|
||||||
|
*
|
||||||
|
* The QS panel collapses after each tap, destroying the [AncTileService] instance.
|
||||||
|
* If the pending-job state lived on the service, taps across consecutive panel
|
||||||
|
* sessions wouldn't cancel each other — every tap would fire a separate
|
||||||
|
* `SetAncMode`, overwhelming the AAP verification loop and triggering
|
||||||
|
* "Rejected after retry" storms that leave the device unresponsive until the
|
||||||
|
* app restarts. Keeping the job here, on a `@Singleton`, means a tap in panel
|
||||||
|
* session B can cancel the deferred send queued by panel session A.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AncTileSendCoordinator @Inject constructor(
|
||||||
|
@AppScope private val appScope: CoroutineScope,
|
||||||
|
private val aapManager: AapConnectionManager,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
private val pendingJobs = mutableMapOf<BluetoothAddress, Job>()
|
||||||
|
private val timeoutJobs = mutableMapOf<BluetoothAddress, Job>()
|
||||||
|
|
||||||
|
private val _pendingModes = MutableStateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>>(emptyMap())
|
||||||
|
val pendingModes: StateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>> = _pendingModes.asStateFlow()
|
||||||
|
|
||||||
|
fun scheduleSetAncMode(
|
||||||
|
address: BluetoothAddress,
|
||||||
|
mode: AapSetting.AncMode.Value,
|
||||||
|
debounce: Duration,
|
||||||
|
timeout: Duration = 5.seconds,
|
||||||
|
) {
|
||||||
|
synchronized(lock) {
|
||||||
|
val replacing = pendingJobs[address]?.isActive == true
|
||||||
|
log(TAG, VERBOSE) { "scheduleSetAncMode($mode, addr=$address, debounce=$debounce, replacingPending=$replacing)" }
|
||||||
|
_pendingModes.value = _pendingModes.value + (address to mode)
|
||||||
|
|
||||||
|
pendingJobs.remove(address)?.cancel()
|
||||||
|
timeoutJobs.remove(address)?.cancel()
|
||||||
|
|
||||||
|
pendingJobs[address] = appScope.launch {
|
||||||
|
delay(debounce)
|
||||||
|
log(TAG, VERBOSE) { "debounce elapsed, dispatching SetAncMode($mode) to AAP for $address" }
|
||||||
|
try {
|
||||||
|
aapManager.sendCommand(address, AapCommand.SetAncMode(mode))
|
||||||
|
log(TAG, VERBOSE) { "sent SetAncMode($mode) to $address" }
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
log(TAG, VERBOSE) { "send for $mode cancelled (newer tap superseded it)" }
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, ERROR) { "sendCommand failed: ${e.asLog()}" }
|
||||||
|
clearPendingTarget(address, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutJobs[address] = appScope.launch {
|
||||||
|
delay(timeout)
|
||||||
|
if (clearPendingTargetFromTimeout(address, mode)) {
|
||||||
|
log(TAG, VERBOSE) { "pending tile target $mode timed out before device confirmation" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun applyPendingTarget(state: AncTileState): AncTileState {
|
||||||
|
val active = state as? AncTileState.Active ?: return state
|
||||||
|
val address = active.deviceAddress ?: return active
|
||||||
|
val target = pendingModes.value[address] ?: return active
|
||||||
|
|
||||||
|
if (target !in active.visible) return active
|
||||||
|
|
||||||
|
if (active.isConfirmed(target)) return active
|
||||||
|
|
||||||
|
return active.copy(pending = target)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun acknowledgeDeviceState(state: AncTileState) {
|
||||||
|
val active = state as? AncTileState.Active ?: return
|
||||||
|
val address = active.deviceAddress ?: return
|
||||||
|
val target = pendingModes.value[address] ?: return
|
||||||
|
|
||||||
|
if (target !in active.visible || active.isConfirmed(target)) {
|
||||||
|
clearPendingTarget(address, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AncTileState.Active.isConfirmed(target: AapSetting.AncMode.Value): Boolean =
|
||||||
|
pending == target || (current == target && pending == null)
|
||||||
|
|
||||||
|
private fun clearPendingTarget(
|
||||||
|
address: BluetoothAddress,
|
||||||
|
expectedMode: AapSetting.AncMode.Value,
|
||||||
|
): Boolean = synchronized(lock) {
|
||||||
|
val current = _pendingModes.value[address] ?: return@synchronized false
|
||||||
|
if (current != expectedMode) return@synchronized false
|
||||||
|
|
||||||
|
_pendingModes.value = _pendingModes.value - address
|
||||||
|
pendingJobs.remove(address)?.cancel()
|
||||||
|
timeoutJobs.remove(address)?.cancel()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearPendingTargetFromTimeout(
|
||||||
|
address: BluetoothAddress,
|
||||||
|
expectedMode: AapSetting.AncMode.Value,
|
||||||
|
): Boolean = synchronized(lock) {
|
||||||
|
val current = _pendingModes.value[address] ?: return@synchronized false
|
||||||
|
if (current != expectedMode) return@synchronized false
|
||||||
|
|
||||||
|
_pendingModes.value = _pendingModes.value - address
|
||||||
|
pendingJobs.remove(address)?.cancel()
|
||||||
|
timeoutJobs.remove(address)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("Tile", "Anc", "Coord")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package eu.darken.capod.main.ui.tile
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.service.quicksettings.Tile
|
||||||
|
import android.service.quicksettings.TileService
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
|
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.hasApiLevel
|
||||||
|
import eu.darken.capod.main.ui.MainActivity
|
||||||
|
import eu.darken.capod.main.ui.components.iconDrawableRes
|
||||||
|
import eu.darken.capod.main.ui.components.shortLabel
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick Settings tile that cycles ANC modes for the user-perceived primary AirPods.
|
||||||
|
* Mirrors [eu.darken.capod.main.ui.widget.AncGlanceWidget] but renders into the system
|
||||||
|
* QS panel instead of a home-screen widget.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AncTileService : TileService() {
|
||||||
|
|
||||||
|
@Inject lateinit var dispatcherProvider: DispatcherProvider
|
||||||
|
@Inject lateinit var sendCoordinator: AncTileSendCoordinator
|
||||||
|
@Inject lateinit var stateStore: AncTileStateStore
|
||||||
|
|
||||||
|
private var listenScope: CoroutineScope? = null
|
||||||
|
private val instanceId = Integer.toHexString(System.identityHashCode(this))
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
log(TAG, VERBOSE) { "onCreate(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTileAdded() {
|
||||||
|
log(TAG, VERBOSE) { "onTileAdded(instance=$instanceId)" }
|
||||||
|
// Render a static placeholder so we never block SystemUI's bind path on a
|
||||||
|
// first-emission DataStore/Billing read. onStartListening will fill in real state.
|
||||||
|
renderTile(AncTileState.Connecting)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartListening() {
|
||||||
|
log(TAG, VERBOSE) { "onStartListening(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
|
||||||
|
listenScope?.cancel()
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.Default)
|
||||||
|
listenScope = scope
|
||||||
|
scope.launch {
|
||||||
|
stateStore.state.throttleLatest(250).collect { state ->
|
||||||
|
log(TAG, VERBOSE) { "collector: state=$state" }
|
||||||
|
withContext(dispatcherProvider.Main) { renderTile(state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStopListening() {
|
||||||
|
log(TAG, VERBOSE) { "onStopListening(instance=$instanceId)" }
|
||||||
|
listenScope?.cancel()
|
||||||
|
listenScope = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
log(TAG, VERBOSE) { "onDestroy(instance=$instanceId)" }
|
||||||
|
lastDestroyAt = now
|
||||||
|
listenScope?.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick() {
|
||||||
|
log(TAG, VERBOSE) { "onClick(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()}) received tap" }
|
||||||
|
val state = stateStore.currentState()
|
||||||
|
log(TAG, VERBOSE) {
|
||||||
|
"onClick: resolved from state store=$state"
|
||||||
|
}
|
||||||
|
dispatchClick(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispatchClick(state: AncTileState) {
|
||||||
|
log(TAG, VERBOSE) { "dispatchClick($state)" }
|
||||||
|
when (state) {
|
||||||
|
AncTileState.NotPro,
|
||||||
|
AncTileState.PermissionRequired -> openMainActivityWithUpgrade()
|
||||||
|
is AncTileState.Active -> sendNextMode(state)
|
||||||
|
AncTileState.BluetoothOff,
|
||||||
|
AncTileState.NoDevice,
|
||||||
|
AncTileState.NoAncSupport,
|
||||||
|
AncTileState.NotConnected,
|
||||||
|
AncTileState.Connecting -> {
|
||||||
|
// Tile state is STATE_UNAVAILABLE; system shouldn't deliver clicks here,
|
||||||
|
// but defensively no-op so we don't crash on unexpected delivery.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendNextMode(state: AncTileState.Active) {
|
||||||
|
val nextMode = pickNextMode(state.visible, state.current, state.pending)
|
||||||
|
log(TAG, VERBOSE) {
|
||||||
|
"sendNextMode: visible=${state.visible} current=${state.current} pending=${state.pending} -> next=$nextMode"
|
||||||
|
}
|
||||||
|
if (nextMode == null || nextMode == (state.pending ?: state.current)) {
|
||||||
|
log(TAG, VERBOSE) { "sendNextMode: no advance possible (visible=${state.visible})" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val address: BluetoothAddress = state.deviceAddress ?: run {
|
||||||
|
log(TAG, WARN) { "sendNextMode: state has no device address" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCoordinator.scheduleSetAncMode(address, nextMode, 1.seconds)
|
||||||
|
|
||||||
|
val optimisticState = state.copy(pending = nextMode)
|
||||||
|
renderTile(optimisticState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openMainActivityWithUpgrade() {
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
putExtra(MainActivity.EXTRA_NAVIGATE_TO_UPGRADE, true)
|
||||||
|
}
|
||||||
|
if (hasApiLevel(34)) {
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
startActivityAndCollapse(pendingIntent)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
|
||||||
|
startActivityAndCollapse(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderTile(state: AncTileState) {
|
||||||
|
val tile = qsTile ?: run {
|
||||||
|
log(TAG, VERBOSE) { "renderTile: skipped, qsTile is null (state=$state)" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log(TAG, VERBOSE) { "renderTile: $state" }
|
||||||
|
val baseLabel = getString(R.string.tile_anc_label)
|
||||||
|
|
||||||
|
val (subtitle, iconRes, tileState) = when (state) {
|
||||||
|
AncTileState.NotPro -> Triple(
|
||||||
|
getString(R.string.common_upgrade_required_label),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_INACTIVE,
|
||||||
|
)
|
||||||
|
AncTileState.PermissionRequired -> Triple(
|
||||||
|
getString(R.string.tile_anc_subtitle_permission_required),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_INACTIVE,
|
||||||
|
)
|
||||||
|
AncTileState.BluetoothOff -> Triple(
|
||||||
|
getString(R.string.tile_anc_subtitle_bluetooth_off),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
AncTileState.NoDevice -> Triple(
|
||||||
|
getString(R.string.tile_anc_subtitle_no_device),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
AncTileState.NoAncSupport -> Triple(
|
||||||
|
getString(R.string.tile_anc_subtitle_no_anc_support),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
AncTileState.NotConnected -> Triple(
|
||||||
|
getString(R.string.anc_widget_aap_not_connected_label),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
AncTileState.Connecting -> Triple(
|
||||||
|
getString(R.string.anc_widget_aap_connecting_label),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
is AncTileState.Active -> {
|
||||||
|
val displayMode = state.pending?.takeIf { it in state.visible } ?: state.current
|
||||||
|
Triple(
|
||||||
|
displayMode.shortLabel(this),
|
||||||
|
displayMode.iconDrawableRes(),
|
||||||
|
Tile.STATE_ACTIVE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tile.icon = Icon.createWithResource(this, iconRes)
|
||||||
|
tile.state = tileState
|
||||||
|
if (hasApiLevel(29)) {
|
||||||
|
tile.label = baseLabel
|
||||||
|
tile.subtitle = subtitle
|
||||||
|
} else {
|
||||||
|
// Pre-API 29 tiles can't show a subtitle; fold it into the label.
|
||||||
|
tile.label = "$baseLabel · $subtitle"
|
||||||
|
}
|
||||||
|
if (hasApiLevel(30)) {
|
||||||
|
tile.stateDescription = subtitle
|
||||||
|
}
|
||||||
|
tile.updateTile()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("Tile", "Anc")
|
||||||
|
@Volatile private var lastDestroyAt: Long? = null
|
||||||
|
|
||||||
|
private fun elapsedSinceLastDestroy(): String {
|
||||||
|
val destroyedAt = lastDestroyAt ?: return "n/a"
|
||||||
|
return "${SystemClock.elapsedRealtime() - destroyedAt}ms"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package eu.darken.capod.main.ui.tile
|
||||||
|
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
|
import eu.darken.capod.common.permissions.Permission
|
||||||
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.visibleAncModes
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure data → data mapper. Decides which [AncTileState] to render given a snapshot
|
||||||
|
* of inputs. Mirrors [AncWidgetRenderStateMapper]'s precedence so the two surfaces
|
||||||
|
* stay in sync, while remaining unit-testable without Android resources.
|
||||||
|
*
|
||||||
|
* Note: cached/offline-tier devices land in [AncTileState.NotConnected] (not
|
||||||
|
* [AncTileState.Connecting]) — without an active AAP session, "Connecting…" would
|
||||||
|
* be misleading because nothing is in progress.
|
||||||
|
*/
|
||||||
|
object AncTileStateMapper {
|
||||||
|
|
||||||
|
fun map(
|
||||||
|
device: PodDevice?,
|
||||||
|
isPro: Boolean,
|
||||||
|
isBluetoothEnabled: Boolean,
|
||||||
|
missingPermissions: Set<Permission>,
|
||||||
|
): AncTileState {
|
||||||
|
if (!isPro) return AncTileState.NotPro
|
||||||
|
if (missingPermissions.any { it.isTileBlocking }) return AncTileState.PermissionRequired
|
||||||
|
if (!isBluetoothEnabled) return AncTileState.BluetoothOff
|
||||||
|
if (device == null) return AncTileState.NoDevice
|
||||||
|
if (!device.hasAncControl) return AncTileState.NoAncSupport
|
||||||
|
if (!device.isAapConnected) return AncTileState.NotConnected
|
||||||
|
if (!device.isAapReady) return AncTileState.Connecting
|
||||||
|
|
||||||
|
val ancMode = device.ancMode ?: return AncTileState.Connecting
|
||||||
|
val visible = device.visibleAncModes
|
||||||
|
if (visible.isEmpty()) return AncTileState.Connecting
|
||||||
|
|
||||||
|
return AncTileState.Active(
|
||||||
|
current = ancMode.current,
|
||||||
|
pending = device.pendingAncMode,
|
||||||
|
visible = visible,
|
||||||
|
deviceLabel = device.label,
|
||||||
|
deviceAddress = device.address,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A permission whose absence prevents the tile from working. Scan-blocking permissions
|
||||||
|
* gate BLE; [Permission.BLUETOOTH_CONNECT] gates the AAP L2CAP socket — without either,
|
||||||
|
* the tile cannot do useful work.
|
||||||
|
*/
|
||||||
|
private val Permission.isTileBlocking: Boolean
|
||||||
|
get() = isScanBlocking || this == Permission.BLUETOOTH_CONNECT
|
||||||
|
|
||||||
|
sealed interface AncTileState {
|
||||||
|
data object NotPro : AncTileState
|
||||||
|
data object PermissionRequired : AncTileState
|
||||||
|
data object BluetoothOff : AncTileState
|
||||||
|
data object NoDevice : AncTileState
|
||||||
|
data object NoAncSupport : AncTileState
|
||||||
|
data object NotConnected : AncTileState
|
||||||
|
data object Connecting : AncTileState
|
||||||
|
data class Active(
|
||||||
|
val current: AapSetting.AncMode.Value,
|
||||||
|
val pending: AapSetting.AncMode.Value?,
|
||||||
|
val visible: List<AapSetting.AncMode.Value>,
|
||||||
|
val deviceLabel: String?,
|
||||||
|
val deviceAddress: BluetoothAddress?,
|
||||||
|
) : AncTileState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycles to the next mode in [visible]. Anchors on [pending] when it's still in
|
||||||
|
* [visible] so rapid taps walk forward through the list rather than oscillate against
|
||||||
|
* the device-echoed [current]. A [pending] that has been filtered out (e.g. user toggled
|
||||||
|
* Allow Off mid-cycle) falls through to [current].
|
||||||
|
*/
|
||||||
|
internal fun pickNextMode(
|
||||||
|
visible: List<AapSetting.AncMode.Value>,
|
||||||
|
current: AapSetting.AncMode.Value?,
|
||||||
|
pending: AapSetting.AncMode.Value?,
|
||||||
|
): AapSetting.AncMode.Value? {
|
||||||
|
if (visible.isEmpty()) return null
|
||||||
|
val anchor = pending?.takeIf { it in visible } ?: current ?: return visible.first()
|
||||||
|
val idx = visible.indexOf(anchor)
|
||||||
|
return if (idx < 0) visible.first() else visible[(idx + 1) % visible.size]
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package eu.darken.capod.main.ui.tile
|
||||||
|
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
|
import eu.darken.capod.common.flow.combine
|
||||||
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.primaryByTier
|
||||||
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.flow.combine as combineFlows
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-scoped state holder for the ANC QS tile.
|
||||||
|
*
|
||||||
|
* TileService instances are short-lived and can disappear between taps. Keeping
|
||||||
|
* the latest state here lets a recreated service resolve clicks synchronously
|
||||||
|
* while the app process is alive. A cold or hydrating process intentionally
|
||||||
|
* reports [AncTileState.Connecting] so the tile UI is honest about not being
|
||||||
|
* ready for input yet.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AncTileStateStore @Inject constructor(
|
||||||
|
@AppScope private val appScope: CoroutineScope,
|
||||||
|
deviceMonitor: DeviceMonitor,
|
||||||
|
profilesRepo: DeviceProfilesRepo,
|
||||||
|
upgradeRepo: UpgradeRepo,
|
||||||
|
bluetoothManager: BluetoothManager2,
|
||||||
|
permissionTool: PermissionTool,
|
||||||
|
private val sendCoordinator: AncTileSendCoordinator,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val rawState: StateFlow<AncTileState> = combine(
|
||||||
|
deviceMonitor.devices,
|
||||||
|
profilesRepo.profiles,
|
||||||
|
upgradeRepo.upgradeInfo.map { it.isPro },
|
||||||
|
bluetoothManager.isBluetoothEnabled,
|
||||||
|
permissionTool.missingPermissions,
|
||||||
|
) { devices, profiles, isPro, isBluetoothEnabled, missingPermissions ->
|
||||||
|
val profileOrder = profiles.mapIndexed { idx, p -> p.id to idx }.toMap()
|
||||||
|
val device = devices.primaryByTier(profileOrder)
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = device,
|
||||||
|
isPro = isPro,
|
||||||
|
isBluetoothEnabled = isBluetoothEnabled,
|
||||||
|
missingPermissions = missingPermissions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(
|
||||||
|
scope = appScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = AncTileState.Connecting,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
appScope.launch {
|
||||||
|
rawState.collect { state -> sendCoordinator.acknowledgeDeviceState(state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val state: StateFlow<AncTileState> = combineFlows(
|
||||||
|
rawState,
|
||||||
|
sendCoordinator.pendingModes,
|
||||||
|
) { rawState, _ ->
|
||||||
|
sendCoordinator.applyPendingTarget(rawState)
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(
|
||||||
|
scope = appScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = AncTileState.Connecting,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun currentState(): AncTileState = sendCoordinator.applyPendingTarget(rawState.value)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.main.ui.components.iconDrawableRes
|
||||||
import eu.darken.capod.main.ui.components.shortLabel
|
import eu.darken.capod.main.ui.components.shortLabel
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.visibleAncModes
|
import eu.darken.capod.monitor.core.visibleAncModes
|
||||||
@@ -57,13 +58,21 @@ object AncWidgetRenderStateMapper {
|
|||||||
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
|
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
|
||||||
)
|
)
|
||||||
|
|
||||||
!device.isAapConnected -> AncWidgetRenderState.Message(
|
!device.isAapConnected -> {
|
||||||
theme = theme,
|
val secondaryRes = if (device.ble != null) {
|
||||||
resolvedBgColor = bgColor,
|
R.string.anc_widget_aap_not_connected_nearby_description
|
||||||
resolvedTextColor = textColor,
|
} else {
|
||||||
resolvedIconColor = iconColor,
|
R.string.anc_widget_aap_not_connected_not_nearby_description
|
||||||
primaryText = context.getString(R.string.anc_widget_aap_not_connected_label),
|
}
|
||||||
)
|
AncWidgetRenderState.Message(
|
||||||
|
theme = theme,
|
||||||
|
resolvedBgColor = bgColor,
|
||||||
|
resolvedTextColor = textColor,
|
||||||
|
resolvedIconColor = iconColor,
|
||||||
|
primaryText = profileLabel ?: device.getLabel(context),
|
||||||
|
secondaryText = context.getString(secondaryRes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
!device.isAapReady -> AncWidgetRenderState.Message(
|
!device.isAapReady -> AncWidgetRenderState.Message(
|
||||||
theme = theme,
|
theme = theme,
|
||||||
@@ -121,10 +130,3 @@ object AncWidgetRenderStateMapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
|
|
||||||
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
|
|
||||||
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
|
|
||||||
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
|
|
||||||
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -160,7 +160,7 @@ private fun SinglePodPreview(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(state.percent.toBatteryOrNull()),
|
text = formatPercent(state.percent),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
@@ -311,7 +311,7 @@ private fun PodItemRow(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(percent.toBatteryOrNull()),
|
text = formatPercent(percent),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.padding(horizontal = 4.dp),
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
@@ -354,7 +354,7 @@ private fun TinyPodItem(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(percent.toBatteryOrNull()),
|
text = formatPercent(percent),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -382,9 +382,8 @@ private fun DeviceLabel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatPercent(percent: Float?): String {
|
private fun formatPercent(percent: Float): String =
|
||||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||||
}
|
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import androidx.glance.text.TextAlign
|
|||||||
import androidx.glance.text.TextStyle
|
import androidx.glance.text.TextStyle
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.main.ui.MainActivity
|
import eu.darken.capod.main.ui.MainActivity
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -122,7 +122,7 @@ private fun GlanceSinglePod(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(state.percent.toBatteryOrNull()),
|
text = formatGlancePercent(state.percent),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||||
)
|
)
|
||||||
@@ -270,7 +270,7 @@ private fun GlancePodItem(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
text = formatGlancePercent(percent),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||||
)
|
)
|
||||||
@@ -312,7 +312,7 @@ private fun GlanceTinyPodItem(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
text = formatGlancePercent(percent),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
modifier = GlanceModifier.padding(start = 4.dp),
|
modifier = GlanceModifier.padding(start = 4.dp),
|
||||||
@@ -341,6 +341,5 @@ private fun GlanceDeviceLabel(
|
|||||||
|
|
||||||
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
||||||
|
|
||||||
private fun formatGlancePercent(percent: Float?): String {
|
private fun formatGlancePercent(percent: Float): String =
|
||||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ internal data class WidgetDeviceKey(
|
|||||||
val profileId: String?,
|
val profileId: String?,
|
||||||
val profileLabel: String?,
|
val profileLabel: String?,
|
||||||
val model: PodModel,
|
val model: PodModel,
|
||||||
val batteryLeft: Float?,
|
val batteryLeft: Float,
|
||||||
val batteryRight: Float?,
|
val batteryRight: Float,
|
||||||
val batteryCase: Float?,
|
val batteryCase: Float,
|
||||||
val batteryHeadset: Float?,
|
val batteryHeadset: Float,
|
||||||
val isLeftPodCharging: Boolean?,
|
val isLeftPodCharging: Boolean?,
|
||||||
val isRightPodCharging: Boolean?,
|
val isRightPodCharging: Boolean?,
|
||||||
val isCaseCharging: Boolean?,
|
val isCaseCharging: Boolean?,
|
||||||
@@ -27,6 +27,7 @@ internal data class WidgetDeviceKey(
|
|||||||
val isBeingWorn: Boolean?,
|
val isBeingWorn: Boolean?,
|
||||||
val isAapConnected: Boolean,
|
val isAapConnected: Boolean,
|
||||||
val isAapReady: Boolean,
|
val isAapReady: Boolean,
|
||||||
|
val hasBleAdvertisement: Boolean,
|
||||||
val ancMode: AapSetting.AncMode.Value?,
|
val ancMode: AapSetting.AncMode.Value?,
|
||||||
val pendingAncMode: AapSetting.AncMode.Value?,
|
val pendingAncMode: AapSetting.AncMode.Value?,
|
||||||
val visibleAncModes: List<AapSetting.AncMode.Value>,
|
val visibleAncModes: List<AapSetting.AncMode.Value>,
|
||||||
@@ -49,6 +50,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
|||||||
isBeingWorn = isBeingWorn,
|
isBeingWorn = isBeingWorn,
|
||||||
isAapConnected = isAapConnected,
|
isAapConnected = isAapConnected,
|
||||||
isAapReady = isAapReady,
|
isAapReady = isAapReady,
|
||||||
|
hasBleAdvertisement = ble != null,
|
||||||
ancMode = ancMode?.current,
|
ancMode = ancMode?.current,
|
||||||
pendingAncMode = pendingAncMode,
|
pendingAncMode = pendingAncMode,
|
||||||
visibleAncModes = visibleAncModes,
|
visibleAncModes = visibleAncModes,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import eu.darken.capod.R
|
|||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
|
||||||
|
|
||||||
object WidgetRenderStateMapper {
|
object WidgetRenderStateMapper {
|
||||||
|
|
||||||
@@ -43,15 +42,15 @@ object WidgetRenderStateMapper {
|
|||||||
layout = layout,
|
layout = layout,
|
||||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||||
leftIcon = device.leftPodIcon,
|
leftIcon = device.leftPodIcon,
|
||||||
leftPercent = device.batteryLeft.toBatteryFloat(),
|
leftPercent = device.batteryLeft,
|
||||||
leftCharging = device.isLeftPodCharging == true,
|
leftCharging = device.isLeftPodCharging == true,
|
||||||
leftInEar = device.isLeftInEar == true,
|
leftInEar = device.isLeftInEar == true,
|
||||||
rightIcon = device.rightPodIcon,
|
rightIcon = device.rightPodIcon,
|
||||||
rightPercent = device.batteryRight.toBatteryFloat(),
|
rightPercent = device.batteryRight,
|
||||||
rightCharging = device.isRightPodCharging == true,
|
rightCharging = device.isRightPodCharging == true,
|
||||||
rightInEar = device.isRightInEar == true,
|
rightInEar = device.isRightInEar == true,
|
||||||
caseIcon = device.caseIcon,
|
caseIcon = device.caseIcon,
|
||||||
casePercent = device.batteryCase.toBatteryFloat(),
|
casePercent = device.batteryCase,
|
||||||
caseCharging = device.isCaseCharging == true,
|
caseCharging = device.isCaseCharging == true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ object WidgetRenderStateMapper {
|
|||||||
layout = layout,
|
layout = layout,
|
||||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||||
headsetIcon = device.iconRes,
|
headsetIcon = device.iconRes,
|
||||||
percent = device.batteryHeadset.toBatteryFloat(),
|
percent = device.batteryHeadset,
|
||||||
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||||
charging = device.isHeadsetBeingCharged == true,
|
charging = device.isHeadsetBeingCharged == true,
|
||||||
worn = device.isBeingWorn == true,
|
worn = device.isBeingWorn == true,
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
|
|||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
|
import eu.darken.capod.common.debug.Bugs
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.replayingShare
|
import eu.darken.capod.common.flow.replayingShare
|
||||||
@@ -20,6 +23,7 @@ import eu.darken.capod.profiles.core.AppleDeviceProfile
|
|||||||
import eu.darken.capod.profiles.core.toReactionConfig
|
import eu.darken.capod.profiles.core.toReactionConfig
|
||||||
import eu.darken.capod.profiles.core.DeviceProfile
|
import eu.darken.capod.profiles.core.DeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -86,7 +90,34 @@ class DeviceMonitor @Inject constructor(
|
|||||||
connectedAddresses = connectedAddresses,
|
connectedAddresses = connectedAddresses,
|
||||||
)
|
)
|
||||||
}.onEach { liveState ->
|
}.onEach { liveState ->
|
||||||
persistLiveDevices(liveState.liveDevices)
|
persistLiveDevices(liveState.liveDevices + aapOnlyForPersistence(liveState))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AAP-only profiles whose state didn't make it into [LiveMergeState.liveDevices] (no BLE pod
|
||||||
|
* in the current scan). Without this, [persistLiveDevices] would only ever see BLE-backed
|
||||||
|
* devices, and AAP-delivered DeviceInfo (earbud serials, marketing version) for an out-of-BLE
|
||||||
|
* pod would never reach the cache even though the AAP socket is alive.
|
||||||
|
*/
|
||||||
|
private fun aapOnlyForPersistence(state: LiveMergeState): List<PodDevice> {
|
||||||
|
val coveredProfileIds = state.liveDevices.mapNotNull { it.profileId }.toSet()
|
||||||
|
return state.profiles.mapNotNull { profile ->
|
||||||
|
if (profile.id in coveredProfileIds) return@mapNotNull null
|
||||||
|
val aap = state.aapStates.forProfile(profile) ?: return@mapNotNull null
|
||||||
|
PodDevice(
|
||||||
|
profileId = profile.id,
|
||||||
|
label = profile.label,
|
||||||
|
ble = null,
|
||||||
|
aap = aap,
|
||||||
|
profileAddress = profile.address,
|
||||||
|
profileModel = profile.model,
|
||||||
|
profileKeyState = profile.toBleKeyState(),
|
||||||
|
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
||||||
|
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
||||||
|
reactions = profile.toReactionConfig(),
|
||||||
|
isSystemConnected = profile.address in state.connectedAddresses,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val devices: Flow<List<PodDevice>> = combine(
|
val devices: Flow<List<PodDevice>> = combine(
|
||||||
@@ -181,14 +212,25 @@ class DeviceMonitor @Inject constructor(
|
|||||||
dedupedLiveDevices + nonLiveDevices
|
dedupedLiveDevices + nonLiveDevices
|
||||||
}.replayingShare(appScope)
|
}.replayingShare(appScope)
|
||||||
|
|
||||||
|
private val reportedPersistFailures = mutableSetOf<String>()
|
||||||
|
|
||||||
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
||||||
for (device in devices) {
|
for (device in devices) {
|
||||||
val profileId = device.profileId ?: continue
|
val profileId = device.profileId ?: continue
|
||||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
try {
|
||||||
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||||
|
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
||||||
|
|
||||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||||
deviceStateCache.save(profileId, newState)
|
deviceStateCache.save(profileId, newState)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, ERROR) { "Failed to persist state for $profileId: ${e.asLog()}" }
|
||||||
|
if (reportedPersistFailures.add(profileId)) {
|
||||||
|
runCatching { Bugs.report(tag = TAG, message = "persistLiveDevices failed for $profileId", exception = e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package eu.darken.capod.monitor.core
|
||||||
|
|
||||||
|
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||||
|
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
|
||||||
|
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.main.core.MonitorMode
|
||||||
|
import eu.darken.capod.profiles.core.DeviceProfile
|
||||||
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
import eu.darken.capod.profiles.core.toReactionConfig
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class MonitorModeResolver @Inject constructor(
|
||||||
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
|
private val nudgeCapabilityStore: NudgeCapabilityStore,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val effectiveMode: Flow<MonitorMode> = combine(
|
||||||
|
profilesRepo.profiles,
|
||||||
|
nudgeCapabilityStore.availability,
|
||||||
|
) { profiles, nudge ->
|
||||||
|
val primary = profiles.firstOrNull() ?: return@combine MonitorMode.MANUAL
|
||||||
|
primary.requiredMode(nudge)
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach { log(TAG, VERBOSE) { "effectiveMode = $it" } }
|
||||||
|
|
||||||
|
private fun DeviceProfile.requiredMode(nudge: NudgeAvailability): MonitorMode = when {
|
||||||
|
// Match AutoConnect.kt's isNullOrEmpty check — a blank/legacy "" address is no address.
|
||||||
|
address.isNullOrBlank() -> MonitorMode.MANUAL
|
||||||
|
toReactionConfig().autoConnect && nudge != NudgeAvailability.BROKEN -> MonitorMode.ALWAYS
|
||||||
|
else -> MonitorMode.AUTOMATIC
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("Monitor", "ModeResolver")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import eu.darken.capod.pods.core.apple.PodModel
|
|||||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
|
||||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||||
@@ -138,18 +139,24 @@ data class PodDevice(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Battery — AAP preferred, BLE fallback, then cached
|
// Battery — AAP preferred, BLE fallback, then cached. Returns BATTERY_UNKNOWN (-1f)
|
||||||
val batteryLeft: Float?
|
// for unknown to keep the type primitive; this is the boundary that previously emitted
|
||||||
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
|
// Float? and triggered Android 10 ART JIT crashes via R8-merged unbox call sites.
|
||||||
|
val batteryLeft: Float
|
||||||
|
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||||
|
?: cached?.left?.percent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
val batteryRight: Float?
|
val batteryRight: Float
|
||||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
|
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||||
|
?: cached?.right?.percent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
val batteryCase: Float?
|
val batteryCase: Float
|
||||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
|
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||||
|
?: cached?.case?.percent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
val batteryHeadset: Float?
|
val batteryHeadset: Float
|
||||||
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
|
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||||
|
?: cached?.headset?.percent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
/** True when at least one displayed battery value was filled from cache (not live). */
|
/** True when at least one displayed battery value was filled from cache (not live). */
|
||||||
val isBatteryCached: Boolean
|
val isBatteryCached: Boolean
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package eu.darken.capod.monitor.core
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection tier rank used to sort devices by user-perceived "primary":
|
||||||
|
* lower rank = higher priority. System-connected devices come first, then
|
||||||
|
* any live device (BLE or AAP), then profiled-but-offline.
|
||||||
|
*
|
||||||
|
* Distinct from [DeviceMonitor.primaryDevice] which is intentionally
|
||||||
|
* non-tier-ranked for reaction flows that want "any profiled device".
|
||||||
|
*/
|
||||||
|
fun PodDevice.tierRank(): Int = when {
|
||||||
|
isSystemConnected -> 0
|
||||||
|
isLive -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the user-perceived primary profiled device: lowest [tierRank], with
|
||||||
|
* the user's profile-list order as the tiebreaker.
|
||||||
|
*/
|
||||||
|
fun List<PodDevice>.primaryByTier(profileOrder: Map<String, Int>): PodDevice? =
|
||||||
|
filter { it.profileId != null }
|
||||||
|
.minWithOrNull(
|
||||||
|
compareBy<PodDevice> { it.tierRank() }
|
||||||
|
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
||||||
|
)
|
||||||
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.mapLatest
|
|||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
@@ -77,9 +76,7 @@ class AapAutoConnect @Inject constructor(
|
|||||||
|
|
||||||
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
||||||
try {
|
try {
|
||||||
withTimeout(CONNECT_TIMEOUT) {
|
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||||
aapManager.connect(address, bonded.internal, profile.model)
|
|
||||||
}
|
|
||||||
log(TAG) { "AAP connected to $address" }
|
log(TAG) { "AAP connected to $address" }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
||||||
@@ -102,9 +99,7 @@ class AapAutoConnect @Inject constructor(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
|
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
|
||||||
withTimeout(CONNECT_TIMEOUT) {
|
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||||
aapManager.connect(address, bonded.internal, profile.model)
|
|
||||||
}
|
|
||||||
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
|
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
|
||||||
break
|
break
|
||||||
} catch (retryException: Exception) {
|
} catch (retryException: Exception) {
|
||||||
@@ -164,9 +159,7 @@ class AapAutoConnect @Inject constructor(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
||||||
withTimeout(CONNECT_TIMEOUT) {
|
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||||
aapManager.connect(address, bonded.internal, profile.model)
|
|
||||||
}
|
|
||||||
log(TAG) { "AAP reconnected to $address" }
|
log(TAG) { "AAP reconnected to $address" }
|
||||||
break
|
break
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -218,7 +211,7 @@ class AapAutoConnect @Inject constructor(
|
|||||||
val bonded = bluetoothManager.bondedDevices().first()
|
val bonded = bluetoothManager.bondedDevices().first()
|
||||||
.firstOrNull { it.address == address }
|
.firstOrNull { it.address == address }
|
||||||
if (bonded != null) {
|
if (bonded != null) {
|
||||||
aapManager.connect(address, bonded.internal, detectedModel)
|
aapManager.connect(address, bonded.internal!!, detectedModel)
|
||||||
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
|
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -231,6 +224,5 @@ class AapAutoConnect @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Monitor", "AapAutoConnect")
|
private val TAG = logTag("Monitor", "AapAutoConnect")
|
||||||
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
|
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
|
||||||
private val CONNECT_TIMEOUT = 5.seconds
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class StemPressReaction @Inject constructor(
|
|||||||
is StemAction.PreviousTrack -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS)
|
is StemAction.PreviousTrack -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS)
|
||||||
is StemAction.VolumeUp -> mediaControl.adjustVolumeUp()
|
is StemAction.VolumeUp -> mediaControl.adjustVolumeUp()
|
||||||
is StemAction.VolumeDown -> mediaControl.adjustVolumeDown()
|
is StemAction.VolumeDown -> mediaControl.adjustVolumeDown()
|
||||||
is StemAction.Stop -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_STOP)
|
is StemAction.Stop -> mediaControl.sendStop()
|
||||||
is StemAction.FastForward -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)
|
is StemAction.FastForward -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)
|
||||||
is StemAction.Rewind -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_REWIND)
|
is StemAction.Rewind -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_REWIND)
|
||||||
is StemAction.MuteToggle -> mediaControl.toggleMuteMusic()
|
is StemAction.MuteToggle -> mediaControl.toggleMuteMusic()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import eu.darken.capod.common.bluetooth.BluetoothManager2
|
|||||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||||
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
|
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging
|
import eu.darken.capod.common.debug.logging.Logging
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
@@ -27,7 +26,10 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -50,14 +52,69 @@ class BlePodMonitor @Inject constructor(
|
|||||||
private val timeSource: TimeSource,
|
private val timeSource: TimeSource,
|
||||||
private val generalSettings: GeneralSettings,
|
private val generalSettings: GeneralSettings,
|
||||||
bluetoothManager: BluetoothManager2,
|
bluetoothManager: BluetoothManager2,
|
||||||
private val debugSettings: DebugSettings,
|
private val permissionTool: PermissionTool,
|
||||||
permissionTool: PermissionTool,
|
|
||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
|
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
|
||||||
private val cacheLock = Mutex()
|
private val cacheLock = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops all cached observations. The troubleshooter calls this between probe attempts so a
|
||||||
|
* previous compat combo's cached devices (kept up to [STALE_DEVICE_TIMEOUT]) can't leak into
|
||||||
|
* the next attempt and falsely satisfy it — including via [preferCaseContextPod], which would
|
||||||
|
* otherwise hand back a stale snapshot whose timestamp predates the fresh scan.
|
||||||
|
*/
|
||||||
|
suspend fun clearDeviceCache() = cacheLock.withLock {
|
||||||
|
log(TAG) { "clearDeviceCache()" }
|
||||||
|
deviceCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ephemeral override that disables the proximity-pairing scan filter so
|
||||||
|
* the troubleshooter can collect raw BLE broadcasts. Resets to false on
|
||||||
|
* every process start; the troubleshooter is the only writer.
|
||||||
|
*/
|
||||||
|
private val unfilteredOverride = MutableStateFlow(false)
|
||||||
|
|
||||||
|
fun setUnfilteredOverride(enabled: Boolean) {
|
||||||
|
unfilteredOverride.value = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ephemeral override for the three BLE compatibility options. The troubleshooter uses this to
|
||||||
|
* probe combinations without writing the user's persisted settings: while set, it fully replaces
|
||||||
|
* the persisted compat values for the active scan. Resets to null on every process start; the
|
||||||
|
* troubleshooter is the only writer and always clears it when finished, so clearing it restores
|
||||||
|
* the user's original settings for free.
|
||||||
|
*/
|
||||||
|
private val compatOverride = MutableStateFlow<CompatOverride?>(null)
|
||||||
|
|
||||||
|
fun setCompatOverride(override: CompatOverride?) {
|
||||||
|
log(TAG) { "setCompatOverride($override)" }
|
||||||
|
compatOverride.value = override
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CompatOverride(
|
||||||
|
val offloadedFilteringDisabled: Boolean,
|
||||||
|
val offloadedBatchingDisabled: Boolean,
|
||||||
|
val indirectCallback: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Persisted compat settings, transparently replaced by [compatOverride] while it is set. */
|
||||||
|
private val effectiveCompat: Flow<CompatOverride> = combine(
|
||||||
|
compatOverride,
|
||||||
|
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||||
|
generalSettings.isOffloadedBatchingDisabled.flow,
|
||||||
|
generalSettings.useIndirectScanResultCallback.flow,
|
||||||
|
) { override, filteringDisabled, batchingDisabled, indirectCallback ->
|
||||||
|
override ?: CompatOverride(
|
||||||
|
offloadedFilteringDisabled = filteringDisabled,
|
||||||
|
offloadedBatchingDisabled = batchingDisabled,
|
||||||
|
indirectCallback = indirectCallback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val devices: Flow<List<BlePodSnapshot>> = combine(
|
val devices: Flow<List<BlePodSnapshot>> = combine(
|
||||||
permissionTool.missingScanPermissions,
|
permissionTool.missingScanPermissions,
|
||||||
bluetoothManager.isBluetoothEnabled
|
bluetoothManager.isBluetoothEnabled
|
||||||
@@ -89,8 +146,10 @@ class BlePodMonitor @Inject constructor(
|
|||||||
log(
|
log(
|
||||||
TAG,
|
TAG,
|
||||||
Logging.Priority.WARN
|
Logging.Priority.WARN
|
||||||
) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
|
) { "PodMonitor failed due to missing permission, rechecking and retrying: ${cause.asLog()}" }
|
||||||
false
|
permissionTool.recheck()
|
||||||
|
delay(3000)
|
||||||
|
true
|
||||||
} else {
|
} else {
|
||||||
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
||||||
delay(3000)
|
delay(3000)
|
||||||
@@ -130,23 +189,15 @@ class BlePodMonitor @Inject constructor(
|
|||||||
|
|
||||||
private fun createBleScanner() = combine(
|
private fun createBleScanner() = combine(
|
||||||
bleScanModeController.scannerMode,
|
bleScanModeController.scannerMode,
|
||||||
debugSettings.showUnfiltered.flow,
|
unfilteredOverride,
|
||||||
generalSettings.isOffloadedBatchingDisabled.flow,
|
effectiveCompat,
|
||||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
) { scannermode, showUnfiltered, compat ->
|
||||||
generalSettings.useIndirectScanResultCallback.flow,
|
|
||||||
) {
|
|
||||||
scannermode,
|
|
||||||
showUnfiltered,
|
|
||||||
isOffloadedBatchingDisabled,
|
|
||||||
isOffloadedFilteringDisabled,
|
|
||||||
useIndirectScanResultCallback,
|
|
||||||
->
|
|
||||||
ScannerOptions(
|
ScannerOptions(
|
||||||
scannerMode = scannermode,
|
scannerMode = scannermode,
|
||||||
showUnfiltered = showUnfiltered,
|
showUnfiltered = showUnfiltered,
|
||||||
offloadedFilteringDisabled = isOffloadedFilteringDisabled,
|
offloadedFilteringDisabled = compat.offloadedFilteringDisabled,
|
||||||
offloadedBatchingDisabled = isOffloadedBatchingDisabled,
|
offloadedBatchingDisabled = compat.offloadedBatchingDisabled,
|
||||||
disableDirectCallback = useIndirectScanResultCallback,
|
disableDirectCallback = compat.indirectCallback,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.throttleLatest(1000)
|
.throttleLatest(1000)
|
||||||
@@ -160,13 +211,27 @@ class BlePodMonitor @Inject constructor(
|
|||||||
else -> ProximityPairing.getBleScanFilter()
|
else -> ProximityPairing.getBleScanFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
bleScanner.scan(
|
flow {
|
||||||
filters = filters,
|
emitAll(
|
||||||
scannerMode = options.scannerMode,
|
bleScanner.scan(
|
||||||
disableOffloadFiltering = options.offloadedFilteringDisabled,
|
filters = filters,
|
||||||
disableOffloadBatching = options.offloadedBatchingDisabled,
|
scannerMode = options.scannerMode,
|
||||||
disableDirectScanCallback = options.disableDirectCallback,
|
disableOffloadFiltering = options.offloadedFilteringDisabled,
|
||||||
)
|
disableOffloadBatching = options.offloadedBatchingDisabled,
|
||||||
|
disableDirectScanCallback = options.disableDirectCallback,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}.catch { cause ->
|
||||||
|
if (cause is SecurityException) {
|
||||||
|
log(TAG, Logging.Priority.WARN) {
|
||||||
|
"BLE scanner failed due to missing permission, rechecking permissions: ${cause.asLog()}"
|
||||||
|
}
|
||||||
|
permissionTool.recheck()
|
||||||
|
emit(emptyList())
|
||||||
|
} else {
|
||||||
|
throw cause
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.map { it.onlyNewAndUnique() }
|
.map { it.onlyNewAndUnique() }
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ data class CachedDeviceState(
|
|||||||
) {
|
) {
|
||||||
val deviceInfo: AapDeviceInfo?
|
val deviceInfo: AapDeviceInfo?
|
||||||
get() {
|
get() {
|
||||||
if (deviceName == null && serialNumber == null && firmwareVersion == null) return null
|
if (deviceName == null && serialNumber == null && firmwareVersion == null
|
||||||
|
&& leftEarbudSerial == null && rightEarbudSerial == null && marketingVersion == null
|
||||||
|
) return null
|
||||||
return AapDeviceInfo(
|
return AapDeviceInfo(
|
||||||
name = deviceName ?: "",
|
name = deviceName ?: "",
|
||||||
modelNumber = "",
|
modelNumber = "",
|
||||||
|
|||||||
+30
-28
@@ -3,9 +3,11 @@ package eu.darken.capod.monitor.core.cache
|
|||||||
import eu.darken.capod.common.SystemTimeSource
|
import eu.darken.capod.common.SystemTimeSource
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
|
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
|
||||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ import java.time.Instant
|
|||||||
* Returns null if:
|
* Returns null if:
|
||||||
* - The device is not live (cached-only)
|
* - The device is not live (cached-only)
|
||||||
* - The device has no profile
|
* - The device has no profile
|
||||||
* - All live battery values are null
|
* - All live battery values AND live DeviceInfo are null (nothing fresh to persist)
|
||||||
* - The state hasn't changed from [existing] (dedup)
|
* - The state hasn't changed from [existing] (dedup)
|
||||||
*/
|
*/
|
||||||
fun PodDevice.toCachedState(
|
fun PodDevice.toCachedState(
|
||||||
@@ -24,15 +26,18 @@ fun PodDevice.toCachedState(
|
|||||||
if (!isLive) return null
|
if (!isLive) return null
|
||||||
val pid = profileId ?: return null
|
val pid = profileId ?: return null
|
||||||
|
|
||||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
// RAW LIVE EXTRACTION — must NOT use device.batteryLeft etc. (which fall back to cache).
|
||||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
// Reading the unified getter would re-stamp stale cached readings as fresh live data.
|
||||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: BATTERY_UNKNOWN
|
||||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: BATTERY_UNKNOWN
|
||||||
|
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: BATTERY_UNKNOWN
|
||||||
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null
|
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
val liveDeviceInfo = aap?.deviceInfo
|
val liveDeviceInfo = aap?.deviceInfo
|
||||||
|
|
||||||
|
if (!isKnownBattery(liveLeft) && !isKnownBattery(liveRight) &&
|
||||||
|
!isKnownBattery(liveCase) && !isKnownBattery(liveHeadset) && liveDeviceInfo == null
|
||||||
|
) return null
|
||||||
|
|
||||||
val newState = CachedDeviceState(
|
val newState = CachedDeviceState(
|
||||||
profileId = pid,
|
profileId = pid,
|
||||||
model = model,
|
model = model,
|
||||||
@@ -60,40 +65,37 @@ fun PodDevice.toCachedState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeBatterySlot(
|
private fun mergeBatterySlot(
|
||||||
livePercent: Float?,
|
livePercent: Float,
|
||||||
existing: CachedBatterySlot?,
|
existing: CachedBatterySlot?,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
): CachedBatterySlot? {
|
): CachedBatterySlot? {
|
||||||
if (livePercent == null) return existing
|
if (!isKnownBattery(livePercent)) return existing
|
||||||
if (existing == null) return CachedBatterySlot(livePercent, now)
|
val current = existing ?: return CachedBatterySlot(livePercent, now)
|
||||||
|
val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1)
|
||||||
val isStale = Duration.between(existing.updatedAt, now).abs() > Duration.ofMinutes(1)
|
return if (current.percent == livePercent && !isStale) current else CachedBatterySlot(livePercent, now)
|
||||||
return if (existing.percent == livePercent && !isStale) existing else CachedBatterySlot(livePercent, now)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
||||||
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
|
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
|
||||||
if (hasSlotTimestampChanged(old.left, new.left)) return true
|
if (hasSlotChanged(old.left, new.left)) return true
|
||||||
if (hasSlotTimestampChanged(old.right, new.right)) return true
|
if (hasSlotChanged(old.right, new.right)) return true
|
||||||
if (hasSlotTimestampChanged(old.case, new.case)) return true
|
if (hasSlotChanged(old.case, new.case)) return true
|
||||||
if (hasSlotTimestampChanged(old.headset, new.headset)) return true
|
if (hasSlotChanged(old.headset, new.headset)) return true
|
||||||
return old.left?.percent != new.left?.percent
|
return old.isLeftCharging != new.isLeftCharging
|
||||||
|| old.right?.percent != new.right?.percent
|
|
||||||
|| old.case?.percent != new.case?.percent
|
|
||||||
|| old.headset?.percent != new.headset?.percent
|
|
||||||
|| old.isLeftCharging != new.isLeftCharging
|
|
||||||
|| old.isRightCharging != new.isRightCharging
|
|| old.isRightCharging != new.isRightCharging
|
||||||
|| old.isCaseCharging != new.isCaseCharging
|
|| old.isCaseCharging != new.isCaseCharging
|
||||||
|| old.isHeadsetCharging != new.isHeadsetCharging
|
|| old.isHeadsetCharging != new.isHeadsetCharging
|
||||||
|| old.deviceName != new.deviceName
|
|| old.deviceName != new.deviceName
|
||||||
|| old.serialNumber != new.serialNumber
|
|| old.serialNumber != new.serialNumber
|
||||||
|| old.firmwareVersion != new.firmwareVersion
|
|| old.firmwareVersion != new.firmwareVersion
|
||||||
|
|| old.leftEarbudSerial != new.leftEarbudSerial
|
||||||
|
|| old.rightEarbudSerial != new.rightEarbudSerial
|
||||||
|
|| old.marketingVersion != new.marketingVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasSlotTimestampChanged(
|
private fun hasSlotChanged(old: CachedBatterySlot?, new: CachedBatterySlot?): Boolean {
|
||||||
old: CachedBatterySlot?,
|
if (old == null && new == null) return false
|
||||||
new: CachedBatterySlot?,
|
if (old == null || new == null) return true
|
||||||
): Boolean {
|
if (old.percent != new.percent) return true
|
||||||
if (old == null || new == null) return false
|
|
||||||
return Duration.between(old.updatedAt, new.updatedAt).abs() > Duration.ofMinutes(1)
|
return Duration.between(old.updatedAt, new.updatedAt).abs() > Duration.ofMinutes(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
|
|||||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
|
||||||
import eu.darken.capod.common.debug.Bugs
|
import eu.darken.capod.common.debug.Bugs
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
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.Logging.Priority.WARN
|
||||||
@@ -30,6 +29,7 @@ import eu.darken.capod.main.core.MonitorMode
|
|||||||
import eu.darken.capod.main.core.PermissionTool
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
import eu.darken.capod.monitor.core.MonitorCoroutineScope
|
import eu.darken.capod.monitor.core.MonitorCoroutineScope
|
||||||
|
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||||
import eu.darken.capod.monitor.core.primaryDevice
|
import eu.darken.capod.monitor.core.primaryDevice
|
||||||
@@ -42,6 +42,7 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
|||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
|
||||||
import eu.darken.capod.reaction.core.playpause.PlayPause
|
import eu.darken.capod.reaction.core.playpause.PlayPause
|
||||||
import eu.darken.capod.reaction.core.popup.PopUpReaction
|
import eu.darken.capod.reaction.core.popup.PopUpReaction
|
||||||
|
import eu.darken.capod.reaction.core.conversation.ConversationReaction
|
||||||
import eu.darken.capod.reaction.core.sleep.SleepReaction
|
import eu.darken.capod.reaction.core.sleep.SleepReaction
|
||||||
import eu.darken.capod.reaction.ui.popup.PopUpWindow
|
import eu.darken.capod.reaction.ui.popup.PopUpWindow
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -78,9 +79,11 @@ class MonitorService : Service() {
|
|||||||
@Inject lateinit var autoConnect: AutoConnect
|
@Inject lateinit var autoConnect: AutoConnect
|
||||||
@Inject lateinit var popUpReaction: PopUpReaction
|
@Inject lateinit var popUpReaction: PopUpReaction
|
||||||
@Inject lateinit var sleepReaction: SleepReaction
|
@Inject lateinit var sleepReaction: SleepReaction
|
||||||
|
@Inject lateinit var conversationReaction: ConversationReaction
|
||||||
@Inject lateinit var popUpWindow: PopUpWindow
|
@Inject lateinit var popUpWindow: PopUpWindow
|
||||||
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
||||||
@Inject lateinit var aapConnectionManager: AapConnectionManager
|
@Inject lateinit var aapConnectionManager: AapConnectionManager
|
||||||
|
@Inject lateinit var monitorModeResolver: MonitorModeResolver
|
||||||
|
|
||||||
private val monitorScope = MonitorCoroutineScope()
|
private val monitorScope = MonitorCoroutineScope()
|
||||||
private var monitoringJob: Job? = null
|
private var monitoringJob: Job? = null
|
||||||
@@ -88,6 +91,10 @@ class MonitorService : Service() {
|
|||||||
private var foregroundStartFailed = false
|
private var foregroundStartFailed = false
|
||||||
private var injectionComplete = false
|
private var injectionComplete = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var latestNotificationSettings: NotificationSettings =
|
||||||
|
NotificationSettings(useExtraNotification = false, keepAfterDisconnect = false)
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
@SuppressLint("InlinedApi")
|
||||||
private fun promoteToForeground(notification: Notification): Boolean {
|
private fun promoteToForeground(notification: Notification): Boolean {
|
||||||
return try {
|
return try {
|
||||||
@@ -188,27 +195,53 @@ class MonitorService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun doMonitor() {
|
private suspend fun doMonitor() {
|
||||||
|
// Seed snapshot eagerly so onDestroy() has correct values even if we abort below.
|
||||||
|
latestNotificationSettings = NotificationSettings(
|
||||||
|
useExtraNotification = generalSettings.useExtraMonitorNotification.flow.first(),
|
||||||
|
keepAfterDisconnect = generalSettings.keepConnectedNotificationAfterDisconnect.flow.first(),
|
||||||
|
)
|
||||||
|
|
||||||
val permissionsMissingOnStart = permissionTool.missingScanPermissions.first()
|
val permissionsMissingOnStart = permissionTool.missingScanPermissions.first()
|
||||||
if (permissionsMissingOnStart.isNotEmpty()) {
|
if (permissionsMissingOnStart.isNotEmpty()) {
|
||||||
log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" }
|
log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val monitorJob = deviceMonitor.primaryDevice()
|
val deviceFlow = deviceMonitor.primaryDevice()
|
||||||
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
|
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
|
||||||
.distinctUntilChangedBy { it?.toNotificationKey() }
|
.distinctUntilChangedBy { it?.toNotificationKey() }
|
||||||
.throttleLatest(1000)
|
.throttleLatest(1000)
|
||||||
.onEach { currentDevice ->
|
|
||||||
val useExtraNotification = generalSettings.useExtraMonitorNotification.valueBlocking
|
val notificationSettingsFlow = combine(
|
||||||
|
generalSettings.useExtraMonitorNotification.flow,
|
||||||
|
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
||||||
|
) { useExtra, keepAfter ->
|
||||||
|
NotificationSettings(useExtraNotification = useExtra, keepAfterDisconnect = keepAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
val monitorJob = combine(deviceFlow, notificationSettingsFlow) { currentDevice, settings ->
|
||||||
|
currentDevice to settings
|
||||||
|
}
|
||||||
|
.onEach { (currentDevice, settings) ->
|
||||||
|
latestNotificationSettings = settings
|
||||||
|
|
||||||
notificationManager.notify(
|
notificationManager.notify(
|
||||||
MonitorNotifications.NOTIFICATION_ID,
|
MonitorNotifications.NOTIFICATION_ID,
|
||||||
notifications.getNotification(currentDevice, showHint = useExtraNotification),
|
notifications.getNotification(
|
||||||
|
currentDevice,
|
||||||
|
showHint = settings.useExtraNotification,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (generalSettings.useExtraMonitorNotification.valueBlocking && currentDevice != null) {
|
|
||||||
notificationManager.notify(
|
when (val action = decideExtraNotificationAction(currentDevice, settings)) {
|
||||||
|
is ExtraNotificationAction.Post -> notificationManager.notify(
|
||||||
MonitorNotifications.NOTIFICATION_ID_CONNECTED,
|
MonitorNotifications.NOTIFICATION_ID_CONNECTED,
|
||||||
notifications.getNotificationConnected(currentDevice),
|
notifications.getNotificationConnected(action.device),
|
||||||
)
|
)
|
||||||
|
ExtraNotificationAction.Cancel -> notificationManager.cancel(
|
||||||
|
MonitorNotifications.NOTIFICATION_ID_CONNECTED
|
||||||
|
)
|
||||||
|
ExtraNotificationAction.KeepExisting -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch {
|
.catch {
|
||||||
@@ -224,7 +257,7 @@ class MonitorService : Service() {
|
|||||||
emptyFlow()
|
emptyFlow()
|
||||||
} else {
|
} else {
|
||||||
combine(
|
combine(
|
||||||
generalSettings.monitorMode.flow,
|
monitorModeResolver.effectiveMode,
|
||||||
profilesRepo.profiles,
|
profilesRepo.profiles,
|
||||||
bluetoothManager.connectedDevices,
|
bluetoothManager.connectedDevices,
|
||||||
aapConnectionManager.allStates,
|
aapConnectionManager.allStates,
|
||||||
@@ -300,6 +333,11 @@ class MonitorService : Service() {
|
|||||||
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
|
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
|
||||||
.launchIn(monitorScope)
|
.launchIn(monitorScope)
|
||||||
|
|
||||||
|
conversationReaction.monitor()
|
||||||
|
.setupCommonEventHandlers(TAG) { "conversationReaction" }
|
||||||
|
.catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } }
|
||||||
|
.launchIn(monitorScope)
|
||||||
|
|
||||||
log(TAG, VERBOSE) { "Monitor job is active" }
|
log(TAG, VERBOSE) { "Monitor job is active" }
|
||||||
monitorJob.join()
|
monitorJob.join()
|
||||||
log(TAG, VERBOSE) { "Monitor job quit" }
|
log(TAG, VERBOSE) { "Monitor job quit" }
|
||||||
@@ -310,7 +348,8 @@ class MonitorService : Service() {
|
|||||||
monitorScope.cancel("Service destroyed")
|
monitorScope.cancel("Service destroyed")
|
||||||
|
|
||||||
if (injectionComplete) {
|
if (injectionComplete) {
|
||||||
if (generalSettings.useExtraMonitorNotification.valueBlocking && !generalSettings.keepConnectedNotificationAfterDisconnect.valueBlocking) {
|
val snapshot = latestNotificationSettings
|
||||||
|
if (snapshot.useExtraNotification && !snapshot.keepAfterDisconnect) {
|
||||||
try {
|
try {
|
||||||
notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED)
|
notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -363,6 +402,7 @@ private data class NotificationDeviceKey(
|
|||||||
val profileId: String?,
|
val profileId: String?,
|
||||||
val label: String?,
|
val label: String?,
|
||||||
val model: PodModel,
|
val model: PodModel,
|
||||||
|
val isLive: Boolean,
|
||||||
val hasDualPods: Boolean,
|
val hasDualPods: Boolean,
|
||||||
val hasCase: Boolean,
|
val hasCase: Boolean,
|
||||||
val hasEarDetection: Boolean,
|
val hasEarDetection: Boolean,
|
||||||
@@ -387,6 +427,7 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
|
|||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
label = label,
|
label = label,
|
||||||
model = model,
|
model = model,
|
||||||
|
isLive = isLive,
|
||||||
hasDualPods = hasDualPods,
|
hasDualPods = hasDualPods,
|
||||||
hasCase = hasCase,
|
hasCase = hasCase,
|
||||||
hasEarDetection = hasEarDetection,
|
hasEarDetection = hasEarDetection,
|
||||||
@@ -406,3 +447,24 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
|
|||||||
rightPodIcon = rightPodIcon,
|
rightPodIcon = rightPodIcon,
|
||||||
caseIcon = caseIcon,
|
caseIcon = caseIcon,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal data class NotificationSettings(
|
||||||
|
val useExtraNotification: Boolean,
|
||||||
|
val keepAfterDisconnect: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal sealed interface ExtraNotificationAction {
|
||||||
|
object Cancel : ExtraNotificationAction
|
||||||
|
data class Post(val device: PodDevice) : ExtraNotificationAction
|
||||||
|
object KeepExisting : ExtraNotificationAction
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun decideExtraNotificationAction(
|
||||||
|
device: PodDevice?,
|
||||||
|
settings: NotificationSettings,
|
||||||
|
): ExtraNotificationAction = when {
|
||||||
|
!settings.useExtraNotification -> ExtraNotificationAction.Cancel
|
||||||
|
device != null && device.isLive -> ExtraNotificationAction.Post(device)
|
||||||
|
settings.keepAfterDisconnect -> ExtraNotificationAction.KeepExisting
|
||||||
|
else -> ExtraNotificationAction.Cancel
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import eu.darken.capod.monitor.core.PodDevice
|
|||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -150,9 +151,7 @@ class MonitorNotificationViewFactory @Inject constructor(
|
|||||||
setTextViewText(R.id.device, device.getLabel(context))
|
setTextViewText(R.id.device, device.getLabel(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun percentToInt(percent: Float?): Int {
|
private fun percentToInt(percent: Float): Int =
|
||||||
if (percent == null) return 0
|
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
|
||||||
return (percent * 100).roundToInt().coerceIn(0, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ enum class PodModel(
|
|||||||
Features(
|
Features(
|
||||||
hasDualPods = true,
|
hasDualPods = true,
|
||||||
hasCase = true,
|
hasCase = true,
|
||||||
|
hasEarDetection = true,
|
||||||
hasMicrophoneMode = true,
|
hasMicrophoneMode = true,
|
||||||
|
hasEarDetectionToggle = true,
|
||||||
),
|
),
|
||||||
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
|
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
|
||||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||||
@@ -37,7 +39,9 @@ enum class PodModel(
|
|||||||
Features(
|
Features(
|
||||||
hasDualPods = true,
|
hasDualPods = true,
|
||||||
hasCase = true,
|
hasCase = true,
|
||||||
|
hasEarDetection = true,
|
||||||
hasMicrophoneMode = true,
|
hasMicrophoneMode = true,
|
||||||
|
hasEarDetectionToggle = true,
|
||||||
),
|
),
|
||||||
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
|
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
|
||||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||||
@@ -52,10 +56,13 @@ enum class PodModel(
|
|||||||
Features(
|
Features(
|
||||||
hasDualPods = true,
|
hasDualPods = true,
|
||||||
hasCase = true,
|
hasCase = true,
|
||||||
|
hasEarDetection = true,
|
||||||
hasPressSpeed = true,
|
hasPressSpeed = true,
|
||||||
hasPressHoldDuration = true,
|
hasPressHoldDuration = true,
|
||||||
hasToneVolume = true,
|
hasToneVolume = true,
|
||||||
|
hasEndCallMuteMic = true,
|
||||||
hasMicrophoneMode = true,
|
hasMicrophoneMode = true,
|
||||||
|
hasEarDetectionToggle = true,
|
||||||
),
|
),
|
||||||
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
|
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
|
||||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||||
@@ -74,6 +81,7 @@ enum class PodModel(
|
|||||||
hasPressSpeed = true,
|
hasPressSpeed = true,
|
||||||
hasPressHoldDuration = true,
|
hasPressHoldDuration = true,
|
||||||
hasToneVolume = true,
|
hasToneVolume = true,
|
||||||
|
hasEndCallMuteMic = true,
|
||||||
hasMicrophoneMode = true,
|
hasMicrophoneMode = true,
|
||||||
hasEarDetectionToggle = true,
|
hasEarDetectionToggle = true,
|
||||||
hasSleepDetection = true,
|
hasSleepDetection = true,
|
||||||
@@ -314,7 +322,6 @@ enum class PodModel(
|
|||||||
"Beats Solo Pro",
|
"Beats Solo Pro",
|
||||||
R.drawable.device_beats_headphones,
|
R.drawable.device_beats_headphones,
|
||||||
Features(
|
Features(
|
||||||
hasEarDetection = true,
|
|
||||||
hasAncControl = true,
|
hasAncControl = true,
|
||||||
),
|
),
|
||||||
modelNumbers = setOf("A1881"), // headphones
|
modelNumbers = setOf("A1881"), // headphones
|
||||||
@@ -343,7 +350,6 @@ enum class PodModel(
|
|||||||
"Beats Studio 3",
|
"Beats Studio 3",
|
||||||
R.drawable.device_beats_studio3,
|
R.drawable.device_beats_studio3,
|
||||||
Features(
|
Features(
|
||||||
hasEarDetection = true,
|
|
||||||
hasAncControl = true,
|
hasAncControl = true,
|
||||||
),
|
),
|
||||||
modelNumbers = setOf("A1914"), // headphones
|
modelNumbers = setOf("A1914"), // headphones
|
||||||
@@ -410,6 +416,7 @@ enum class PodModel(
|
|||||||
hasDualPods = true,
|
hasDualPods = true,
|
||||||
hasCase = true,
|
hasCase = true,
|
||||||
hasEarDetection = true,
|
hasEarDetection = true,
|
||||||
|
hasEarDetectionToggle = true,
|
||||||
),
|
),
|
||||||
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
|
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
|
||||||
leftPodIconRes = R.drawable.device_powerbeats_pro_left,
|
leftPodIconRes = R.drawable.device_powerbeats_pro_left,
|
||||||
@@ -426,6 +433,7 @@ enum class PodModel(
|
|||||||
hasCase = true,
|
hasCase = true,
|
||||||
hasEarDetection = true,
|
hasEarDetection = true,
|
||||||
hasAncControl = true,
|
hasAncControl = true,
|
||||||
|
hasMicrophoneMode = true,
|
||||||
hasEarDetectionToggle = true,
|
hasEarDetectionToggle = true,
|
||||||
hasSleepDetection = true,
|
hasSleepDetection = true,
|
||||||
),
|
),
|
||||||
@@ -444,6 +452,8 @@ enum class PodModel(
|
|||||||
hasCase = true,
|
hasCase = true,
|
||||||
hasEarDetection = true,
|
hasEarDetection = true,
|
||||||
hasAncControl = true,
|
hasAncControl = true,
|
||||||
|
hasMicrophoneMode = true,
|
||||||
|
hasEarDetectionToggle = true,
|
||||||
),
|
),
|
||||||
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
|
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
|
||||||
leftPodIconRes = R.drawable.device_beats_fitpro_left,
|
leftPodIconRes = R.drawable.device_beats_fitpro_left,
|
||||||
@@ -458,6 +468,7 @@ enum class PodModel(
|
|||||||
Features(
|
Features(
|
||||||
hasDualPods = true,
|
hasDualPods = true,
|
||||||
hasCase = true,
|
hasCase = true,
|
||||||
|
hasEarDetection = true,
|
||||||
),
|
),
|
||||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||||
@@ -471,6 +482,7 @@ enum class PodModel(
|
|||||||
Features(
|
Features(
|
||||||
hasDualPods = true,
|
hasDualPods = true,
|
||||||
hasCase = true,
|
hasCase = true,
|
||||||
|
hasEarDetection = true,
|
||||||
),
|
),
|
||||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||||
@@ -484,6 +496,7 @@ enum class PodModel(
|
|||||||
Features(
|
Features(
|
||||||
hasDualPods = true,
|
hasDualPods = true,
|
||||||
hasCase = true,
|
hasCase = true,
|
||||||
|
hasEarDetection = true,
|
||||||
),
|
),
|
||||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||||
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import eu.darken.capod.pods.core.apple.PodModel
|
|||||||
import eu.darken.capod.pods.core.apple.aap.engine.AapConnection
|
import eu.darken.capod.pods.core.apple.aap.engine.AapConnection
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
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.AapDeviceProfile
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -73,6 +74,17 @@ class AapConnectionManager @Inject constructor(
|
|||||||
private val _sleepEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
private val _sleepEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||||
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
|
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits when a connected device reports a Conversational Awareness speaking transition
|
||||||
|
* (START/STOP, AAP command 0x4B). Paired with the origin address so the conversation
|
||||||
|
* reaction can gate on the primary device. Only the known 0x01/0x04 markers reach here —
|
||||||
|
* the engine drops unknown raw values (see [AapSessionEngine]).
|
||||||
|
*/
|
||||||
|
private val _conversationalAwarenessEvents =
|
||||||
|
MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>(extraBufferCapacity = 16)
|
||||||
|
val conversationalAwarenessEvents: SharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>> =
|
||||||
|
_conversationalAwarenessEvents.asSharedFlow()
|
||||||
|
|
||||||
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
|
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
|
||||||
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||||
val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow()
|
val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow()
|
||||||
@@ -140,6 +152,13 @@ class AapConnectionManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forward conversational-awareness speaking transitions from this connection (child coroutine).
|
||||||
|
launch {
|
||||||
|
connection.conversationalAwarenessEvents.collect { event ->
|
||||||
|
_conversationalAwarenessEvents.tryEmit(address to event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Forward OFF-rejection events from this connection (child coroutine)
|
// Forward OFF-rejection events from this connection (child coroutine)
|
||||||
launch {
|
launch {
|
||||||
connection.offRejected.collect {
|
connection.offRejected.collect {
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ 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.AapFramer
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
|
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.AapSleepEvent
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -26,7 +28,11 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages a single AAP L2CAP connection to a device.
|
* Manages a single AAP L2CAP connection to a device.
|
||||||
@@ -39,6 +45,7 @@ internal class AapConnection(
|
|||||||
private val profile: AapDeviceProfile,
|
private val profile: AapDeviceProfile,
|
||||||
private val socketFactory: L2capSocketFactory,
|
private val socketFactory: L2capSocketFactory,
|
||||||
timeSource: TimeSource,
|
timeSource: TimeSource,
|
||||||
|
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val engine = AapSessionEngine(profile, timeSource)
|
private val engine = AapSessionEngine(profile, timeSource)
|
||||||
@@ -47,6 +54,7 @@ internal class AapConnection(
|
|||||||
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
|
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
|
||||||
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
|
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
|
||||||
val sleepEvents: SharedFlow<AapSleepEvent> get() = engine.sleepEvents
|
val sleepEvents: SharedFlow<AapSleepEvent> get() = engine.sleepEvents
|
||||||
|
val conversationalAwarenessEvents: SharedFlow<ConversationAwarenessEvent> get() = engine.conversationalAwarenessEvents
|
||||||
val offRejected: SharedFlow<Unit> get() = engine.offRejected
|
val offRejected: SharedFlow<Unit> get() = engine.offRejected
|
||||||
val settingRejected: SharedFlow<AapCommand> get() = engine.settingRejected
|
val settingRejected: SharedFlow<AapCommand> get() = engine.settingRejected
|
||||||
|
|
||||||
@@ -68,7 +76,7 @@ internal class AapConnection(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
val sock = socketFactory.createSocket(device, PSM)
|
val sock = socketFactory.createSocket(device, PSM)
|
||||||
sock.connect()
|
sock.connectCancellable()
|
||||||
socket = sock
|
socket = sock
|
||||||
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
|
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
|
||||||
|
|
||||||
@@ -212,15 +220,47 @@ internal class AapConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun cleanupSocket() {
|
private fun cleanupSocket() {
|
||||||
|
socket?.closeQuietly()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun BluetoothSocket.connectCancellable() {
|
||||||
|
val result = CompletableDeferred<Result<Unit>>()
|
||||||
|
val cancelled = AtomicBoolean(false)
|
||||||
|
val connectThread = Thread(
|
||||||
|
{
|
||||||
|
val outcome = runCatching { connect() }
|
||||||
|
result.complete(outcome)
|
||||||
|
if (cancelled.get() && outcome.isSuccess) closeQuietly()
|
||||||
|
},
|
||||||
|
"AAP-L2CAP-connect-${device.address}",
|
||||||
|
).apply {
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
socket?.close()
|
withTimeout(connectTimeout) {
|
||||||
|
result.await().getOrThrow()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
cancelled.set(true)
|
||||||
|
closeQuietly()
|
||||||
|
connectThread.interrupt()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BluetoothSocket.closeQuietly() {
|
||||||
|
try {
|
||||||
|
close()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
socket = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PSM = 0x1001
|
private const val PSM = 0x1001
|
||||||
|
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
|
||||||
private val TAG = logTag("AAP", "Connection")
|
private val TAG = logTag("AAP", "Connection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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.AapPacket
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
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.AapSleepEvent
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -54,6 +55,11 @@ internal class AapSessionEngine(
|
|||||||
MutableSharedFlow<AapSleepEvent>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
MutableSharedFlow<AapSleepEvent>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
val sleepEvents: SharedFlow<AapSleepEvent> = _sleepEvents.asSharedFlow()
|
val sleepEvents: SharedFlow<AapSleepEvent> = _sleepEvents.asSharedFlow()
|
||||||
|
|
||||||
|
private val _conversationalAwarenessEvents =
|
||||||
|
MutableSharedFlow<ConversationAwarenessEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
|
val conversationalAwarenessEvents: SharedFlow<ConversationAwarenessEvent> =
|
||||||
|
_conversationalAwarenessEvents.asSharedFlow()
|
||||||
|
|
||||||
private val _offRejected =
|
private val _offRejected =
|
||||||
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow()
|
val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow()
|
||||||
@@ -301,6 +307,13 @@ internal class AapSessionEngine(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-emit every (well-formed) Conversational Awareness frame as a classified event for the
|
||||||
|
// conversation reaction: START / STOP / HOLD (keep-alive). The decoder already dropped
|
||||||
|
// malformed frames (rawValue stays null only in that case). The raw payload remains logged.
|
||||||
|
if (value is AapSetting.ConversationalAwarenessState) {
|
||||||
|
value.rawValue?.let { _conversationalAwarenessEvents.tryEmit(ConversationAwarenessEvent.fromStatus(it)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleTimerFired(key: EngineTimerKey) {
|
private fun handleTimerFired(key: EngineTimerKey) {
|
||||||
|
|||||||
@@ -99,9 +99,17 @@ sealed class AapSetting {
|
|||||||
val level: Int,
|
val level: Int,
|
||||||
) : AapSetting()
|
) : AapSetting()
|
||||||
|
|
||||||
/** Push-only from device — reports speaking detection state (command 0x4B). */
|
/**
|
||||||
|
* Push-only from device — reports speaking detection state (command 0x4B).
|
||||||
|
*
|
||||||
|
* [rawValue] is the status byte: the last byte of the 4-byte `02 00 01 XX` form (or the single
|
||||||
|
* byte of the legacy form), preserved so consumers can classify it. [speaking] is `true` only
|
||||||
|
* for the speaking-onset statuses (`1`, `2`); every other value (`0`, `3`, `4`, `5`, `0x0B`, …)
|
||||||
|
* is `false`. START/STOP/HOLD classification for the reaction lives in [ConversationAwarenessEvent].
|
||||||
|
*/
|
||||||
data class ConversationalAwarenessState(
|
data class ConversationalAwarenessState(
|
||||||
val speaking: Boolean,
|
val speaking: Boolean,
|
||||||
|
val rawValue: Int? = null,
|
||||||
) : AapSetting()
|
) : AapSetting()
|
||||||
|
|
||||||
data class MicrophoneMode(
|
data class MicrophoneMode(
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classified Conversational Awareness signal derived from the status byte of a `0x4B` frame.
|
||||||
|
*
|
||||||
|
* Status-byte mapping (from live AirPods Pro 3 captures + the librepods project):
|
||||||
|
* - `1`, `2` → [START] (wearer started / is speaking → engage the reaction)
|
||||||
|
* - `5`, `6`, `8`, `9` → [STOP] (wearer stopped → disengage). `5` is the terminal value on fw `…6861`,
|
||||||
|
* which winds down `3`→`5` and never reaches `6/8/9`; `6/8/9` are the terminal values on fw `…6503`.
|
||||||
|
* - any other value (`0`, `3`, `4`, `0x0B`, … and anything unrecognised) → [HOLD]: a transitional or
|
||||||
|
* unknown frame. It must NOT disengage the reaction — only an explicit terminal [STOP] does that.
|
||||||
|
*
|
||||||
|
* Frame cadence is firmware-dependent and the pod does NOT reliably stream keep-alives while you
|
||||||
|
* talk: fw `…6861` sent only an onset (`1`,`2`) then NO `0x4B` frames for 21s of continuous speech
|
||||||
|
* (proven still-speaking — it held its own CA/ANC-transparency engaged the whole time), then the
|
||||||
|
* wind-down `3`,`5`. So frame-silence must NOT be read as "speaking ended"; disengage is driven by
|
||||||
|
* the explicit terminal [STOP] frame. [ConversationReaction]'s stale timeout is only a long backstop
|
||||||
|
* for a fully-dropped terminal frame, not the normal disengage path.
|
||||||
|
*/
|
||||||
|
enum class ConversationAwarenessEvent {
|
||||||
|
START,
|
||||||
|
HOLD,
|
||||||
|
STOP,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val SPEAKING_STATUSES = setOf(1, 2)
|
||||||
|
val STOPPED_STATUSES = setOf(5, 6, 8, 9)
|
||||||
|
|
||||||
|
fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) {
|
||||||
|
in SPEAKING_STATUSES -> START
|
||||||
|
in STOPPED_STATUSES -> STOP
|
||||||
|
else -> HOLD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-5
@@ -183,12 +183,24 @@ class DefaultAapDeviceProfile(
|
|||||||
return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets)
|
return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conversation Awareness State is a separate command type (push-only)
|
// Conversation Awareness State is a separate command type (push-only). Two payload shapes
|
||||||
|
// are seen: a legacy single byte, and the 4-byte `02 00 01 <status>` form on Pro 3 fw 81.x.
|
||||||
|
// The status (speaking marker) is the last byte in both — but we validate the shape rather
|
||||||
|
// than blindly taking payload.last(), so a truncated/garbled frame can't be misread as a
|
||||||
|
// speaking transition. Unrecognised shapes return null → logged as an unhandled message.
|
||||||
if (message.commandType == AapMessageType.CONVERSATIONAL_AWARENESS.value) {
|
if (message.commandType == AapMessageType.CONVERSATIONAL_AWARENESS.value) {
|
||||||
if (message.payload.isEmpty()) return null
|
val p = message.payload
|
||||||
val value = message.payload[0].toInt() and 0xFF
|
val status = when {
|
||||||
val speaking = value == 0x01
|
p.size == 1 -> p[0].toInt() and 0xFF
|
||||||
return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking)
|
p.size == 4 &&
|
||||||
|
p[0] == 0x02.toByte() && p[1] == 0x00.toByte() && p[2] == 0x01.toByte() ->
|
||||||
|
p[3].toInt() and 0xFF
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
// speaking = the "started/active speaking" statuses (1,2); see ConversationAwarenessEvent.
|
||||||
|
val speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES
|
||||||
|
return AapSetting.ConversationalAwarenessState::class to
|
||||||
|
AapSetting.ConversationalAwarenessState(speaking, rawValue = status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.commandType != AapMessageType.CONTROL.value) return null
|
if (message.commandType != AapMessageType.CONTROL.value) return null
|
||||||
|
|||||||
@@ -21,17 +21,18 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
const val BATTERY_UNKNOWN = -1f
|
const val BATTERY_UNKNOWN = -1f
|
||||||
|
|
||||||
fun Float?.toBatteryFloat(): Float = this ?: BATTERY_UNKNOWN
|
fun isKnownBattery(percent: Float): Boolean = percent.isFinite() && percent >= 0f
|
||||||
|
|
||||||
fun Float.toBatteryOrNull(): Float? = takeIf { it >= 0f }
|
fun batteryProgress(percent: Float): Float =
|
||||||
|
if (isKnownBattery(percent)) percent.coerceIn(0f, 1f) else 0f
|
||||||
|
|
||||||
fun formatBatteryPercent(context: Context, percent: Float?): String =
|
fun formatBatteryPercent(context: Context, percent: Float): String =
|
||||||
percent?.let { "${(it * 100).roundToInt()}%" }
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
|
||||||
?: context.getString(R.string.general_value_not_available_label)
|
else context.getString(R.string.general_value_not_available_label)
|
||||||
|
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
fun getBatteryDrawable(percent: Float?): Int = when {
|
fun getBatteryDrawable(percent: Float): Int = when {
|
||||||
percent == null -> R.drawable.ic_baseline_battery_unknown_24
|
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
|
||||||
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
|
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
|
||||||
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
|
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
|
||||||
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
|
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
|
||||||
@@ -42,8 +43,8 @@ fun getBatteryDrawable(percent: Float?): Int = when {
|
|||||||
else -> R.drawable.ic_baseline_battery_0_bar_24
|
else -> R.drawable.ic_baseline_battery_0_bar_24
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBatteryIcon(percent: Float?): ImageVector = when {
|
fun getBatteryIcon(percent: Float): ImageVector = when {
|
||||||
percent == null -> Icons.AutoMirrored.TwoTone.BatteryUnknown
|
!isKnownBattery(percent) -> Icons.AutoMirrored.TwoTone.BatteryUnknown
|
||||||
percent > 0.95f -> Icons.TwoTone.BatteryFull
|
percent > 0.95f -> Icons.TwoTone.BatteryFull
|
||||||
percent > 0.80f -> Icons.TwoTone.Battery6Bar
|
percent > 0.80f -> Icons.TwoTone.Battery6Bar
|
||||||
percent > 0.65f -> Icons.TwoTone.Battery5Bar
|
percent > 0.65f -> Icons.TwoTone.Battery5Bar
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
|||||||
import eu.darken.capod.pods.core.apple.ble.history.KnownDevice
|
import eu.darken.capod.pods.core.apple.ble.history.KnownDevice
|
||||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
|
||||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far back to look when recovering the lid state from a recent in-case-pod broadcast.
|
||||||
|
* Time-bounded rather than count-bounded: BLE scan batching means a fixed number of frames is not a
|
||||||
|
* stable time window, and an old reading must not be allowed to resurrect a stale OPEN/CLOSED.
|
||||||
|
*/
|
||||||
|
private val MAX_LID_RECOVERY_AGE: Duration = Duration.ofSeconds(2)
|
||||||
|
|
||||||
interface ApplePodsFactory {
|
interface ApplePodsFactory {
|
||||||
fun isResponsible(message: ProximityMessage): Boolean
|
fun isResponsible(message: ProximityMessage): Boolean
|
||||||
@@ -22,7 +30,8 @@ interface ApplePodsFactory {
|
|||||||
fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery
|
fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery
|
||||||
|
|
||||||
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
|
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
|
||||||
// A pod broadcasting from inside the case has authoritative case state
|
// A pod broadcasting from inside the case has authoritative case state. Out-of-case frames
|
||||||
|
// (one pod removed) report UNKNOWN via DualApplePods.caseLidState and are skipped here.
|
||||||
if (basic.hasCaseContext && basic.caseLidState in setOf(
|
if (basic.hasCaseContext && basic.caseLidState in setOf(
|
||||||
DualApplePods.LidState.OPEN,
|
DualApplePods.LidState.OPEN,
|
||||||
DualApplePods.LidState.CLOSED,
|
DualApplePods.LidState.CLOSED,
|
||||||
@@ -31,11 +40,14 @@ interface ApplePodsFactory {
|
|||||||
return basic.caseLidState
|
return basic.caseLidState
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN.
|
// Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN (out-of-case pod's
|
||||||
// Check recent history for a sibling broadcast that has case context.
|
// stale lid byte). Recover the last authoritative reading from a recent in-case broadcast,
|
||||||
val fromCaseContext = history
|
// bounded by time so a missed CLOSED can't keep a stale OPEN alive across scan gaps.
|
||||||
.takeLast(4)
|
val recentHistory = history
|
||||||
.filterIsInstance<DualApplePods>()
|
.filterIsInstance<DualApplePods>()
|
||||||
|
.filter { Duration.between(it.seenLastAt, basic.seenLastAt).abs() <= MAX_LID_RECOVERY_AGE }
|
||||||
|
|
||||||
|
val fromCaseContext = recentHistory
|
||||||
.lastOrNull { it.hasCaseContext && it.caseLidState != DualApplePods.LidState.UNKNOWN }
|
.lastOrNull { it.hasCaseContext && it.caseLidState != DualApplePods.LidState.UNKNOWN }
|
||||||
?.caseLidState
|
?.caseLidState
|
||||||
|
|
||||||
@@ -44,10 +56,8 @@ interface ApplePodsFactory {
|
|||||||
// No case-context broadcast in recent history — current value is best we have
|
// No case-context broadcast in recent history — current value is best we have
|
||||||
if (basic.caseLidState != DualApplePods.LidState.UNKNOWN) return basic.caseLidState
|
if (basic.caseLidState != DualApplePods.LidState.UNKNOWN) return basic.caseLidState
|
||||||
|
|
||||||
// Last resort: any non-UNKNOWN from history
|
// Last resort: any non-UNKNOWN from recent history
|
||||||
return history
|
return recentHistory
|
||||||
.takeLast(2)
|
|
||||||
.filterIsInstance<DualApplePods>()
|
|
||||||
.lastOrNull { it.caseLidState != DualApplePods.LidState.UNKNOWN }
|
.lastOrNull { it.caseLidState != DualApplePods.LidState.UNKNOWN }
|
||||||
?.caseLidState
|
?.caseLidState
|
||||||
?: DualApplePods.LidState.NOT_IN_CASE
|
?: DualApplePods.LidState.NOT_IN_CASE
|
||||||
|
|||||||
@@ -148,7 +148,14 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
|
|||||||
get() = isThisPodInThecase || isOnePodInCase || areBothPodsInCase
|
get() = isThisPodInThecase || isOnePodInCase || areBothPodsInCase
|
||||||
|
|
||||||
val caseLidState: LidState
|
val caseLidState: LidState
|
||||||
get() = LidState.fromRaw(pubCaseLidState, hasCaseContext)
|
get() = LidState.fromRaw(
|
||||||
|
raw = pubCaseLidState,
|
||||||
|
hasCaseContext = hasCaseContext,
|
||||||
|
// The lid bit is only trustworthy from a pod broadcasting inside the case (bit 6), or
|
||||||
|
// while both pods are in the case (bit 2). A bit4-only frame comes from the out-of-case
|
||||||
|
// pod and carries a stale lid byte (see LidState.fromRaw).
|
||||||
|
lidReadingReliable = isThisPodInThecase || areBothPodsInCase,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO this is glitchy
|
* TODO this is glitchy
|
||||||
@@ -165,8 +172,20 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
|
|||||||
UNKNOWN;
|
UNKNOWN;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromRaw(raw: UByte, hasCaseContext: Boolean): LidState {
|
/**
|
||||||
|
* Derives the lid state from the raw lid byte.
|
||||||
|
*
|
||||||
|
* The open/closed bit is only meaningful when broadcast by a pod that is itself inside
|
||||||
|
* the case ([isThisPodInThecase]) or while both pods are in the case ([areBothPodsInCase]).
|
||||||
|
* When only one pod is in the case and the *other*, out-of-case pod is the one
|
||||||
|
* broadcasting (bit4-only), its lid byte is stale and decodes to a phantom OPEN even while
|
||||||
|
* the case is physically shut (verified on AirPods Pro 1 & Pro 3). Such frames must report
|
||||||
|
* [UNKNOWN] so they don't drive case-open reactions; consumers recover the real state from
|
||||||
|
* an in-case-pod broadcast instead.
|
||||||
|
*/
|
||||||
|
fun fromRaw(raw: UByte, hasCaseContext: Boolean, lidReadingReliable: Boolean): LidState {
|
||||||
if (!hasCaseContext) return NOT_IN_CASE
|
if (!hasCaseContext) return NOT_IN_CASE
|
||||||
|
if (!lidReadingReliable) return UNKNOWN
|
||||||
|
|
||||||
return when ((raw.toInt() shr 3) and 0x01) {
|
return when ((raw.toInt() shr 3) and 0x01) {
|
||||||
0 -> OPEN
|
0 -> OPEN
|
||||||
|
|||||||
+11
-1
@@ -44,8 +44,18 @@ data class AirPodsMax2(
|
|||||||
return pubFlags.isBitSet(0)
|
return pubFlags.isBitSet(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aggregate "any wear sensor active" — NOT "both earcups worn".
|
||||||
|
// Observed on A3454: bit 5 of pubStatus is always set while advertising and
|
||||||
|
// no longer carries the wear flag (unlike Max gen 1). Bits 1 and 3 of
|
||||||
|
// pubStatus reflect the two earcup sensors (same byte positions used by
|
||||||
|
// DualApplePods, but without the primary/flip semantics).
|
||||||
|
//
|
||||||
|
// We OR them because some Android pairings only see one of the two bits
|
||||||
|
// reliably (issue #548: "both worn" advertises as 0x23 — bit 1 only —
|
||||||
|
// while macOS sees 0x2B with both bits set). AND-ing would falsely report
|
||||||
|
// "not worn" during normal use on those phones.
|
||||||
override val isBeingWorn: Boolean
|
override val isBeingWorn: Boolean
|
||||||
get() = pubStatus.isBitSet(5)
|
get() = pubStatus.isBitSet(1) || pubStatus.isBitSet(3)
|
||||||
|
|
||||||
class Factory @Inject constructor(
|
class Factory @Inject constructor(
|
||||||
private val repo: PodHistoryRepo,
|
private val repo: PodHistoryRepo,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import eu.darken.capod.pods.core.apple.PodModel
|
|||||||
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
|
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
|
||||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||||
|
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||||
import eu.darken.capod.reaction.core.stem.StemActionsConfig
|
import eu.darken.capod.reaction.core.stem.StemActionsConfig
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
@@ -25,11 +26,14 @@ data class AppleDeviceProfile(
|
|||||||
@SerialName("address") override val address: String? = null,
|
@SerialName("address") override val address: String? = null,
|
||||||
@SerialName("reactionAutoPause") val autoPause: Boolean = false,
|
@SerialName("reactionAutoPause") val autoPause: Boolean = false,
|
||||||
@SerialName("reactionAutoPlay") val autoPlay: Boolean = false,
|
@SerialName("reactionAutoPlay") val autoPlay: Boolean = false,
|
||||||
|
@SerialName("reactionStartMusicOnWear") val startMusicOnWear: Boolean = false,
|
||||||
@SerialName("reactionOnePodMode") val onePodMode: Boolean = false,
|
@SerialName("reactionOnePodMode") val onePodMode: Boolean = false,
|
||||||
@SerialName("reactionAutoConnect") val autoConnect: Boolean = false,
|
@SerialName("reactionAutoConnect") val autoConnect: Boolean = false,
|
||||||
@SerialName("reactionAutoConnectCondition") val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
|
@SerialName("reactionAutoConnectCondition") val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
|
||||||
@SerialName("reactionShowPopUpOnCaseOpen") val showPopUpOnCaseOpen: Boolean = false,
|
@SerialName("reactionShowPopUpOnCaseOpen") val showPopUpOnCaseOpen: Boolean = false,
|
||||||
@SerialName("reactionShowPopUpOnConnection") val showPopUpOnConnection: Boolean = false,
|
@SerialName("reactionShowPopUpOnConnection") val showPopUpOnConnection: Boolean = false,
|
||||||
|
@SerialName("reactionConversationAction") val conversationAction: ConversationAction = ConversationAction.NOTHING,
|
||||||
|
@SerialName("reactionConversationVolumeReduction") val conversationVolumeReduction: Int = ReactionConfig.DEFAULT_CONVERSATION_VOLUME_REDUCTION,
|
||||||
/**
|
/**
|
||||||
* Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor
|
* Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor
|
||||||
* the learned value across sessions — AAP state is dropped on disconnect, but whether OFF
|
* the learned value across sessions — AAP state is dropped on disconnect, but whether OFF
|
||||||
@@ -49,11 +53,14 @@ data class AppleDeviceProfile(
|
|||||||
get() = ReactionConfig(
|
get() = ReactionConfig(
|
||||||
autoPause = autoPause,
|
autoPause = autoPause,
|
||||||
autoPlay = autoPlay,
|
autoPlay = autoPlay,
|
||||||
|
startMusicOnWear = startMusicOnWear,
|
||||||
onePodMode = onePodMode,
|
onePodMode = onePodMode,
|
||||||
autoConnect = autoConnect,
|
autoConnect = autoConnect,
|
||||||
autoConnectCondition = autoConnectCondition,
|
autoConnectCondition = autoConnectCondition,
|
||||||
showPopUpOnCaseOpen = showPopUpOnCaseOpen,
|
showPopUpOnCaseOpen = showPopUpOnCaseOpen,
|
||||||
showPopUpOnConnection = showPopUpOnConnection,
|
showPopUpOnConnection = showPopUpOnConnection,
|
||||||
|
conversationAction = conversationAction,
|
||||||
|
conversationVolumeReduction = conversationVolumeReduction,
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun toString(): String = "AppleDeviceProfile(" +
|
override fun toString(): String = "AppleDeviceProfile(" +
|
||||||
@@ -62,10 +69,13 @@ data class AppleDeviceProfile(
|
|||||||
"identityKey=${if (identityKey == null) "null" else "<redacted>"}, " +
|
"identityKey=${if (identityKey == null) "null" else "<redacted>"}, " +
|
||||||
"encryptionKey=${if (encryptionKey == null) "null" else "<redacted>"}, " +
|
"encryptionKey=${if (encryptionKey == null) "null" else "<redacted>"}, " +
|
||||||
"address=$address, autoPause=$autoPause, autoPlay=$autoPlay, " +
|
"address=$address, autoPause=$autoPause, autoPlay=$autoPlay, " +
|
||||||
|
"startMusicOnWear=$startMusicOnWear, " +
|
||||||
"onePodMode=$onePodMode, autoConnect=$autoConnect, " +
|
"onePodMode=$onePodMode, autoConnect=$autoConnect, " +
|
||||||
"autoConnectCondition=$autoConnectCondition, " +
|
"autoConnectCondition=$autoConnectCondition, " +
|
||||||
"showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " +
|
"showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " +
|
||||||
"showPopUpOnConnection=$showPopUpOnConnection, " +
|
"showPopUpOnConnection=$showPopUpOnConnection, " +
|
||||||
|
"conversationAction=$conversationAction, " +
|
||||||
|
"conversationVolumeReduction=$conversationVolumeReduction, " +
|
||||||
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
|
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
|
||||||
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
|
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
|
||||||
"stemActions=$stemActions" +
|
"stemActions=$stemActions" +
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
package eu.darken.capod.profiles.core
|
package eu.darken.capod.profiles.core
|
||||||
|
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||||
|
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||||
|
|
||||||
data class ReactionConfig(
|
data class ReactionConfig(
|
||||||
val autoPause: Boolean = false,
|
val autoPause: Boolean = false,
|
||||||
val autoPlay: Boolean = false,
|
val autoPlay: Boolean = false,
|
||||||
|
val startMusicOnWear: Boolean = false,
|
||||||
val onePodMode: Boolean = false,
|
val onePodMode: Boolean = false,
|
||||||
val autoConnect: Boolean = false,
|
val autoConnect: Boolean = false,
|
||||||
val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
|
val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
|
||||||
val showPopUpOnCaseOpen: Boolean = false,
|
val showPopUpOnCaseOpen: Boolean = false,
|
||||||
val showPopUpOnConnection: Boolean = false,
|
val showPopUpOnConnection: Boolean = false,
|
||||||
)
|
val conversationAction: ConversationAction = ConversationAction.NOTHING,
|
||||||
|
/** Percentage to lower media volume by when [conversationAction] is LOWER_VOLUME (clamped 10..90 on use). */
|
||||||
|
val conversationVolumeReduction: Int = DEFAULT_CONVERSATION_VOLUME_REDUCTION,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_CONVERSATION_VOLUME_REDUCTION = 50
|
||||||
|
const val MIN_CONVERSATION_VOLUME_REDUCTION = 10
|
||||||
|
|
||||||
|
/** 100% lowers media volume all the way to 0 — i.e. full mute while speaking. */
|
||||||
|
const val MAX_CONVERSATION_VOLUME_REDUCTION = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface HasReactionConfig {
|
interface HasReactionConfig {
|
||||||
val reactionConfig: ReactionConfig
|
val reactionConfig: ReactionConfig
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package eu.darken.capod.reaction.core.autoconnect
|
package eu.darken.capod.reaction.core.autoconnect
|
||||||
|
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
|
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||||
|
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
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.Logging.Priority.WARN
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
@@ -26,6 +28,7 @@ import javax.inject.Singleton
|
|||||||
class AutoConnect @Inject constructor(
|
class AutoConnect @Inject constructor(
|
||||||
private val bluetoothManager: BluetoothManager2,
|
private val bluetoothManager: BluetoothManager2,
|
||||||
private val deviceMonitor: DeviceMonitor,
|
private val deviceMonitor: DeviceMonitor,
|
||||||
|
private val nudgeCapabilityStore: NudgeCapabilityStore,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun monitor(): Flow<Unit> = deviceMonitor.primaryDevice()
|
fun monitor(): Flow<Unit> = deviceMonitor.primaryDevice()
|
||||||
@@ -36,7 +39,9 @@ class AutoConnect @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
bluetoothManager.connectedDevices,
|
bluetoothManager.connectedDevices,
|
||||||
deviceMonitor.primaryDevice().filterNotNull().distinctUntilChangedBy {
|
deviceMonitor.primaryDevice().filterNotNull().distinctUntilChangedBy {
|
||||||
Triple(it.rawDataHex, it.reactions.autoConnectCondition, it.reactions.onePodMode)
|
// Include caseLidState: for the CASE_OPEN condition it is derived from history
|
||||||
|
// and can flip to OPEN while the selected frame's raw bytes are unchanged.
|
||||||
|
listOf(it.rawDataHex, it.reactions.autoConnectCondition, it.reactions.onePodMode, it.caseLidState)
|
||||||
},
|
},
|
||||||
) { connectedDevices, mainDevice ->
|
) { connectedDevices, mainDevice ->
|
||||||
connectedDevices to mainDevice
|
connectedDevices to mainDevice
|
||||||
@@ -98,13 +103,14 @@ class AutoConnect @Inject constructor(
|
|||||||
return@map
|
return@map
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bluetoothManager.isNudgeAvailable) {
|
if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
|
||||||
log(TAG, WARN) { "nudgeConnection is not available on this device, skipping" }
|
log(TAG, WARN) { "nudgeConnection is known broken on this device, skipping" }
|
||||||
return@map
|
return@map
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = bluetoothManager.nudgeConnection(bondedDevice)
|
val result = bluetoothManager.nudgeConnection(bondedDevice)
|
||||||
log(TAG) { "nudgeConnection($bondedDevice) returned $result" }
|
log(TAG) { "nudgeConnection($bondedDevice) returned $result" }
|
||||||
|
nudgeCapabilityStore.record(result)
|
||||||
}
|
}
|
||||||
.setupCommonEventHandlers(TAG) { "monitor" }
|
.setupCommonEventHandlers(TAG) { "monitor" }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package eu.darken.capod.reaction.core.conversation
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What CAPod does when the pods report that the wearer started speaking (Conversational
|
||||||
|
* Awareness). On Android the firmware does not duck audio on its own, so CAPod performs the
|
||||||
|
* reaction itself. The action is reverted when speaking stops.
|
||||||
|
*
|
||||||
|
* Extend by adding entries — the persisted [SerialName] strings are stable identifiers.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class ConversationAction(
|
||||||
|
val identifier: String,
|
||||||
|
@StringRes val labelRes: Int,
|
||||||
|
) {
|
||||||
|
@SerialName("conversation.action.nothing")
|
||||||
|
NOTHING("conversation.action.nothing", R.string.settings_conversation_action_nothing_label),
|
||||||
|
|
||||||
|
@SerialName("conversation.action.lower_volume")
|
||||||
|
LOWER_VOLUME("conversation.action.lower_volume", R.string.settings_conversation_action_lower_volume_label),
|
||||||
|
|
||||||
|
@SerialName("conversation.action.pause")
|
||||||
|
PAUSE("conversation.action.pause", R.string.settings_conversation_action_pause_label),
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package eu.darken.capod.reaction.core.conversation
|
||||||
|
|
||||||
|
import eu.darken.capod.common.MediaControl
|
||||||
|
import eu.darken.capod.common.TimeSource
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
|
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.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.primaryDevice
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||||
|
import eu.darken.capod.profiles.core.ReactionConfig
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reacts to Conversational Awareness speaking transitions (AAP `0x4B`) by either lowering media
|
||||||
|
* volume or pausing, per the primary device's [ReactionConfig.conversationAction], and reverts when
|
||||||
|
* speaking stops. On Android the pod firmware does not duck audio itself, so CAPod performs it.
|
||||||
|
*
|
||||||
|
* Disengage is driven by the pod's explicit end-of-speech frame ([ConversationAwarenessEvent.STOP]).
|
||||||
|
* The pod does NOT reliably stream keep-alive frames while you talk — on some firmware it sends an
|
||||||
|
* onset ([ConversationAwarenessEvent.START]) then nothing for many seconds while speech continues
|
||||||
|
* (observed: CA held engaged 21s with zero `0x4B` frames). So frame-silence must NOT be read as
|
||||||
|
* "speaking ended". [STALE_TIMEOUT] is only a long backstop for the rare case where every terminal
|
||||||
|
* frame is lost while the link stays up; a link drop is handled by the owner-disconnect revert.
|
||||||
|
* [ConversationAwarenessEvent.HOLD] frames (transitional/unknown statuses) keep the reaction engaged
|
||||||
|
* and refresh the backstop.
|
||||||
|
*
|
||||||
|
* State is a single global slot (media volume / playback is system-wide, not per-device) guarded by
|
||||||
|
* a [Mutex] — events, AAP-state-removal, the stale timer, and monitor completion all mutate it.
|
||||||
|
* Volume ducks are additionally reverted on owner-disconnect and monitor completion so a dropped
|
||||||
|
* session never strands the user at low volume.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ConversationReaction @Inject constructor(
|
||||||
|
private val aapManager: AapConnectionManager,
|
||||||
|
private val deviceMonitor: DeviceMonitor,
|
||||||
|
private val mediaControl: MediaControl,
|
||||||
|
@AppScope private val appScope: CoroutineScope,
|
||||||
|
private val timeSource: TimeSource,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private sealed interface Kind {
|
||||||
|
data object Paused : Kind
|
||||||
|
data class Ducked(val priorVolume: Int, val appliedVolume: Int) : Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Active(
|
||||||
|
val id: Long,
|
||||||
|
val owner: BluetoothAddress,
|
||||||
|
val kind: Kind,
|
||||||
|
val at: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var active: Active? = null
|
||||||
|
private var staleJob: Job? = null
|
||||||
|
private var idCounter = 0L
|
||||||
|
|
||||||
|
fun monitor(): Flow<Unit> = merge(
|
||||||
|
aapManager.conversationalAwarenessEvents.onEach { (address, event) -> onEvent(address, event) },
|
||||||
|
// Reverts a stranded duck when the owning device leaves the AAP state map for any reason
|
||||||
|
// (intentional or not) — disconnectEvents only fires for unintentional drops.
|
||||||
|
aapManager.allStates.onEach { states -> onActiveDevicesChanged(states.keys) },
|
||||||
|
)
|
||||||
|
.map { }
|
||||||
|
// Service stop / scope cancellation: undo any active duck so we don't leave volume lowered.
|
||||||
|
.onCompletion { withContext(NonCancellable) { onMonitorCompleted() } }
|
||||||
|
.setupCommonEventHandlers(TAG) { "conversationReaction" }
|
||||||
|
|
||||||
|
private suspend fun onEvent(address: BluetoothAddress, event: ConversationAwarenessEvent) = when (event) {
|
||||||
|
ConversationAwarenessEvent.START -> onSpeakingStart(address)
|
||||||
|
ConversationAwarenessEvent.HOLD -> onSpeakingHold(address)
|
||||||
|
ConversationAwarenessEvent.STOP -> onSpeakingStop(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun onSpeakingStart(address: BluetoothAddress) {
|
||||||
|
val primary = deviceMonitor.primaryDevice().first()
|
||||||
|
if (primary?.address != address) {
|
||||||
|
log(TAG) { "START from $address ignored — not primary device (primary=${primary?.address})" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val action = primary.reactions.conversationAction
|
||||||
|
if (action == ConversationAction.NOTHING) return
|
||||||
|
|
||||||
|
mutex.withLock {
|
||||||
|
val current = active
|
||||||
|
if (current != null && current.owner == address) {
|
||||||
|
// Duplicate START for the same speaker — don't re-act, just keep the session alive.
|
||||||
|
restartStaleTimer(current)
|
||||||
|
log(TAG) { "START from $address — already active ($action), keep-alive" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A different device started speaking while we were active — undo the old one first.
|
||||||
|
if (current != null) revert(current, "superseded by $address")
|
||||||
|
|
||||||
|
when (action) {
|
||||||
|
ConversationAction.PAUSE -> {
|
||||||
|
val paused = mediaControl.sendPause(rememberForResume = false)
|
||||||
|
if (paused) {
|
||||||
|
val record = Active(nextId(), address, Kind.Paused, timeSource.elapsedRealtime())
|
||||||
|
active = record
|
||||||
|
restartStaleTimer(record)
|
||||||
|
log(TAG, INFO) { "START on $address → paused media" }
|
||||||
|
} else {
|
||||||
|
active = null
|
||||||
|
log(TAG) { "START on $address → nothing playing, no pause" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ConversationAction.LOWER_VOLUME -> {
|
||||||
|
val reduction = primary.reactions.conversationVolumeReduction
|
||||||
|
.coerceIn(
|
||||||
|
ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION,
|
||||||
|
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION,
|
||||||
|
)
|
||||||
|
val duck = mediaControl.duckMusicVolume(reduction)
|
||||||
|
if (duck != null) {
|
||||||
|
val record = Active(
|
||||||
|
nextId(),
|
||||||
|
address,
|
||||||
|
Kind.Ducked(duck.priorVolume, duck.appliedVolume),
|
||||||
|
timeSource.elapsedRealtime(),
|
||||||
|
)
|
||||||
|
active = record
|
||||||
|
restartStaleTimer(record)
|
||||||
|
log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}→${duck.appliedVolume}" }
|
||||||
|
} else {
|
||||||
|
active = null
|
||||||
|
log(TAG) { "START on $address → duck no-op" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ConversationAction.NOTHING -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Intermediate keep-alive frame: the wearer is still speaking — refresh the stale timer. */
|
||||||
|
private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock {
|
||||||
|
val current = active ?: return
|
||||||
|
if (current.owner != address) return
|
||||||
|
restartStaleTimer(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun onSpeakingStop(address: BluetoothAddress) {
|
||||||
|
val primary = deviceMonitor.primaryDevice().first()
|
||||||
|
mutex.withLock {
|
||||||
|
val current = active ?: return
|
||||||
|
if (current.owner != address) {
|
||||||
|
log(TAG) { "STOP from $address ignored — owner is ${current.owner}" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearActive()
|
||||||
|
disengage(current, primary, "STOP on $address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Graceful disengage (STOP event or stale timeout). Must be called under [mutex]. */
|
||||||
|
private suspend fun disengage(record: Active, primary: PodDevice?, reason: String) {
|
||||||
|
when (val kind = record.kind) {
|
||||||
|
is Kind.Paused -> {
|
||||||
|
// Resume the pause WE caused, regardless of the current action setting. Gating on
|
||||||
|
// "action still == PAUSE" would strand media paused if the user switched the action
|
||||||
|
// (or set NOTHING) mid-conversation — undoing our own side effect is the least
|
||||||
|
// surprising behaviour. The remaining guards are about real device/playback state.
|
||||||
|
val age = timeSource.elapsedRealtime() - record.at
|
||||||
|
when {
|
||||||
|
age.milliseconds > PAUSE_RESUME_WINDOW ->
|
||||||
|
log(TAG) { "$reason — resume skipped (stale, ${age}ms)" }
|
||||||
|
primary?.address != record.owner ->
|
||||||
|
log(TAG) { "$reason — resume skipped (primary switched)" }
|
||||||
|
primary.isBeingWorn == false ->
|
||||||
|
log(TAG) { "$reason — resume skipped (not worn)" }
|
||||||
|
mediaControl.isPlaying ->
|
||||||
|
log(TAG) { "$reason — resume skipped (already playing)" }
|
||||||
|
else -> {
|
||||||
|
mediaControl.sendPlay()
|
||||||
|
log(TAG, INFO) { "$reason → resumed media" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Kind.Ducked -> revertDuck(kind, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun onActiveDevicesChanged(addresses: Set<BluetoothAddress>) = mutex.withLock {
|
||||||
|
val current = active ?: return
|
||||||
|
if (current.owner !in addresses) {
|
||||||
|
clearActive()
|
||||||
|
revert(current, "owner ${current.owner} gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun onMonitorCompleted() = mutex.withLock {
|
||||||
|
val current = active ?: return
|
||||||
|
clearActive()
|
||||||
|
revert(current, "monitor completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forced revert (disconnect / supersede / shutdown): undo volume ducks; leave pauses as-is. */
|
||||||
|
private fun revert(record: Active, reason: String) {
|
||||||
|
when (val kind = record.kind) {
|
||||||
|
is Kind.Ducked -> revertDuck(kind, reason)
|
||||||
|
is Kind.Paused -> log(TAG) { "Clearing pause ($reason) — leaving playback as-is" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun revertDuck(kind: Kind.Ducked, reason: String) {
|
||||||
|
// Restore to the pre-duck volume unconditionally — we do NOT gate on
|
||||||
|
// currentMusicVolume() == appliedVolume. Such a guard skips the restore whenever anything
|
||||||
|
// else moved the volume meanwhile (e.g. a concurrent volume-manager app), which lets the
|
||||||
|
// baseline ratchet down across conversations. Restoring deterministically to the level we
|
||||||
|
// saved at engage avoids that; the trade-off is overriding a deliberate mid-conversation
|
||||||
|
// volume change (rare). Matches librepods' unconditional restore.
|
||||||
|
log(TAG, INFO) { "Restoring volume to ${kind.priorVolume} (ducked to ${kind.appliedVolume}, now ${mediaControl.currentMusicVolume()}, $reason)" }
|
||||||
|
mediaControl.restoreMusicVolume(kind.priorVolume)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Must be called under [mutex]. Clears the active slot and cancels its stale timer. */
|
||||||
|
private fun clearActive() {
|
||||||
|
staleJob?.cancel()
|
||||||
|
staleJob = null
|
||||||
|
active = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Must be called under [mutex]. (Re)starts the backstop timer for [record], reset on every frame
|
||||||
|
* (START/HOLD). If no frame arrives for [STALE_TIMEOUT] the session is force-ended — a last
|
||||||
|
* resort for a fully-dropped terminal frame, not the normal disengage. Identity-checked so a
|
||||||
|
* late timer can't disengage a newer session.
|
||||||
|
*/
|
||||||
|
private fun restartStaleTimer(record: Active) {
|
||||||
|
staleJob?.cancel()
|
||||||
|
staleJob = appScope.launch {
|
||||||
|
delay(STALE_TIMEOUT)
|
||||||
|
val primary = deviceMonitor.primaryDevice().first()
|
||||||
|
mutex.withLock {
|
||||||
|
if (active?.id == record.id) {
|
||||||
|
val current = active!!
|
||||||
|
clearActive()
|
||||||
|
disengage(current, primary, "stale timeout (frames ceased)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextId(): Long = ++idCounter
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("Reaction", "Conversation")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure backstop: disengage if no `0x4B` frame arrives for this long while engaged. This is
|
||||||
|
* NOT the normal disengage path — the pod does not stream keep-alives during speech, so a
|
||||||
|
* short timeout would fire mid-conversation (the original 12s value did exactly that).
|
||||||
|
* Normal disengage is the explicit terminal [ConversationAwarenessEvent.STOP] frame; this
|
||||||
|
* only recovers a fully-dropped terminal frame while the link stays up. Kept longer than
|
||||||
|
* [PAUSE_RESUME_WINDOW] so a back-stopped pause is never auto-resumed (only a duck is
|
||||||
|
* restored on stale — a stranded low volume is the worse failure).
|
||||||
|
*/
|
||||||
|
private val STALE_TIMEOUT = 5.minutes
|
||||||
|
|
||||||
|
/** A pause older than this no longer auto-resumes — unexpected late playback is worse. */
|
||||||
|
private val PAUSE_RESUME_WINDOW = 2.minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,6 +179,7 @@ class PlayPause @Inject constructor(
|
|||||||
onePodMode = reactions.onePodMode,
|
onePodMode = reactions.onePodMode,
|
||||||
isCurrentlyPlaying = isCurrentlyPlaying,
|
isCurrentlyPlaying = isCurrentlyPlaying,
|
||||||
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
|
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
|
||||||
|
startMusicOnWear = reactions.startMusicOnWear,
|
||||||
)
|
)
|
||||||
|
|
||||||
// BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources.
|
// BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources.
|
||||||
@@ -204,6 +205,7 @@ class PlayPause @Inject constructor(
|
|||||||
shouldStageBleOnlyPlay = shouldStageBleOnlyPlay,
|
shouldStageBleOnlyPlay = shouldStageBleOnlyPlay,
|
||||||
isCurrentlyPlaying = isCurrentlyPlaying,
|
isCurrentlyPlaying = isCurrentlyPlaying,
|
||||||
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
|
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
|
||||||
|
startMusicOnWear = reactions.startMusicOnWear,
|
||||||
)
|
)
|
||||||
pendingPlayConfirmation = confirmation.pending
|
pendingPlayConfirmation = confirmation.pending
|
||||||
|
|
||||||
@@ -263,7 +265,7 @@ class PlayPause @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
decision.shouldPause && reactions.autoPause -> {
|
decision.shouldPause && reactions.autoPause -> {
|
||||||
val pauseSent = mediaControl.sendPause()
|
val pauseSent = mediaControl.sendPause(rememberForResume = true)
|
||||||
log(TAG, INFO) {
|
log(TAG, INFO) {
|
||||||
"autoPause triggered: source=$source, " +
|
"autoPause triggered: source=$source, " +
|
||||||
"wasWorn=${prevState.bothInEar}, isWorn=${currState.bothInEar}, " +
|
"wasWorn=${prevState.bothInEar}, isWorn=${currState.bothInEar}, " +
|
||||||
@@ -288,10 +290,11 @@ class PlayPause @Inject constructor(
|
|||||||
onePodMode: Boolean,
|
onePodMode: Boolean,
|
||||||
isCurrentlyPlaying: Boolean,
|
isCurrentlyPlaying: Boolean,
|
||||||
wasRecentlyPausedByUs: Boolean = false,
|
wasRecentlyPausedByUs: Boolean = false,
|
||||||
|
startMusicOnWear: Boolean = false,
|
||||||
): PlayPauseDecision = if (onePodMode) {
|
): PlayPauseDecision = if (onePodMode) {
|
||||||
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
|
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear)
|
||||||
} else {
|
} else {
|
||||||
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
|
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun evaluateOnePodMode(
|
private fun evaluateOnePodMode(
|
||||||
@@ -299,6 +302,7 @@ class PlayPause @Inject constructor(
|
|||||||
current: EarDetectionState,
|
current: EarDetectionState,
|
||||||
isCurrentlyPlaying: Boolean,
|
isCurrentlyPlaying: Boolean,
|
||||||
wasRecentlyPausedByUs: Boolean,
|
wasRecentlyPausedByUs: Boolean,
|
||||||
|
startMusicOnWear: Boolean,
|
||||||
): PlayPauseDecision {
|
): PlayPauseDecision {
|
||||||
val netChange = current.podCount - previous.podCount
|
val netChange = current.podCount - previous.podCount
|
||||||
|
|
||||||
@@ -310,8 +314,13 @@ class PlayPause @Inject constructor(
|
|||||||
reason = "One-pod mode: pod(s) removed (net change: $netChange)"
|
reason = "One-pod mode: pod(s) removed (net change: $netChange)"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Net increase: pod(s) inserted → play
|
// Net increase: pod(s) inserted → play, but only when we paused recently OR the user
|
||||||
netChange > 0 && (!isCurrentlyPlaying || wasRecentlyPausedByUs) -> PlayPauseDecision(
|
// opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay
|
||||||
|
// so we never override a user-initiated pause or fire over silence.
|
||||||
|
netChange > 0 && (
|
||||||
|
wasRecentlyPausedByUs ||
|
||||||
|
(startMusicOnWear && !isCurrentlyPlaying)
|
||||||
|
) -> PlayPauseDecision(
|
||||||
shouldPlay = true,
|
shouldPlay = true,
|
||||||
shouldPause = false,
|
shouldPause = false,
|
||||||
reason = "One-pod mode: pod(s) inserted (net change: +$netChange)",
|
reason = "One-pod mode: pod(s) inserted (net change: +$netChange)",
|
||||||
@@ -332,13 +341,19 @@ class PlayPause @Inject constructor(
|
|||||||
current: EarDetectionState,
|
current: EarDetectionState,
|
||||||
isCurrentlyPlaying: Boolean,
|
isCurrentlyPlaying: Boolean,
|
||||||
wasRecentlyPausedByCap: Boolean,
|
wasRecentlyPausedByCap: Boolean,
|
||||||
|
startMusicOnWear: Boolean,
|
||||||
): PlayPauseDecision {
|
): PlayPauseDecision {
|
||||||
val wasWorn = previous.bothInEar
|
val wasWorn = previous.bothInEar
|
||||||
val isWorn = current.bothInEar
|
val isWorn = current.bothInEar
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
// Transition: not worn → worn, and not playing → play
|
// Transition: not worn → worn → play, but only when we paused recently OR the user
|
||||||
!wasWorn && isWorn && (!isCurrentlyPlaying || wasRecentlyPausedByCap) -> PlayPauseDecision(
|
// opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay
|
||||||
|
// so we never override a user-initiated pause or fire over silence.
|
||||||
|
!wasWorn && isWorn && (
|
||||||
|
wasRecentlyPausedByCap ||
|
||||||
|
(startMusicOnWear && !isCurrentlyPlaying)
|
||||||
|
) -> PlayPauseDecision(
|
||||||
shouldPlay = true,
|
shouldPlay = true,
|
||||||
shouldPause = false,
|
shouldPause = false,
|
||||||
reason = "Normal mode: both pods in ear",
|
reason = "Normal mode: both pods in ear",
|
||||||
@@ -371,6 +386,7 @@ class PlayPause @Inject constructor(
|
|||||||
shouldStageBleOnlyPlay: Boolean,
|
shouldStageBleOnlyPlay: Boolean,
|
||||||
isCurrentlyPlaying: Boolean,
|
isCurrentlyPlaying: Boolean,
|
||||||
wasRecentlyPausedByUs: Boolean,
|
wasRecentlyPausedByUs: Boolean,
|
||||||
|
startMusicOnWear: Boolean = false,
|
||||||
): PlayConfirmationResult {
|
): PlayConfirmationResult {
|
||||||
val activePending = pending?.takeIf {
|
val activePending = pending?.takeIf {
|
||||||
it.profileId == profileId && it.onePodMode == onePodMode && autoPlayEnabled
|
it.profileId == profileId && it.onePodMode == onePodMode && autoPlayEnabled
|
||||||
@@ -378,7 +394,10 @@ class PlayPause @Inject constructor(
|
|||||||
|
|
||||||
if (activePending != null &&
|
if (activePending != null &&
|
||||||
currentState == activePending.targetState &&
|
currentState == activePending.targetState &&
|
||||||
(!isCurrentlyPlaying || wasRecentlyPausedByUs)
|
(
|
||||||
|
wasRecentlyPausedByUs ||
|
||||||
|
(startMusicOnWear && !isCurrentlyPlaying)
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return PlayConfirmationResult(
|
return PlayConfirmationResult(
|
||||||
decision = PlayPauseDecision(
|
decision = PlayPauseDecision(
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import eu.darken.capod.monitor.core.DeviceMonitor
|
|||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.primaryDevice
|
import eu.darken.capod.monitor.core.primaryDevice
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
@@ -34,8 +37,12 @@ class PopUpReaction @Inject constructor(
|
|||||||
|
|
||||||
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDevice()
|
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDevice()
|
||||||
.distinctUntilChangedBy {
|
.distinctUntilChangedBy {
|
||||||
// Re-emit on profile changes (eligibility) AND on raw BLE state changes (lid).
|
// Re-emit on profile changes (eligibility), raw BLE changes (content), AND the derived
|
||||||
Triple(it?.profileId, it?.reactions?.showPopUpOnCaseOpen, it?.rawDataHex)
|
// lid state. The latter is essential: caseLidState is recovered from history, so it can
|
||||||
|
// flip OPEN<->CLOSED while the *selected* frame's raw bytes stay identical (e.g. a steady
|
||||||
|
// out-of-case frame while a sibling in-case frame updates). Keying on rawDataHex alone
|
||||||
|
// would swallow that transition and the popup would miss its show/hide.
|
||||||
|
listOf(it?.profileId, it?.reactions?.showPopUpOnCaseOpen, it?.rawDataHex, it?.caseLidState)
|
||||||
}
|
}
|
||||||
.withPrevious()
|
.withPrevious()
|
||||||
.setupCommonEventHandlers(TAG) { "popUpCase" }
|
.setupCommonEventHandlers(TAG) { "popUpCase" }
|
||||||
@@ -71,7 +78,7 @@ class PopUpReaction @Inject constructor(
|
|||||||
throttleCasePopUps(current)
|
throttleCasePopUps(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun throttleCasePopUps(current: PodDevice): Event? {
|
internal fun throttleCasePopUps(current: PodDevice): Event? {
|
||||||
val cooldownKey = current.profileId ?: current.identifier?.toString() ?: return null
|
val cooldownKey = current.profileId ?: current.identifier?.toString() ?: return null
|
||||||
val now = timeSource.now()
|
val now = timeSource.now()
|
||||||
val lastShown = caseCoolDowns[cooldownKey]
|
val lastShown = caseCoolDowns[cooldownKey]
|
||||||
@@ -95,9 +102,9 @@ class PopUpReaction @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
decision.shouldHide -> {
|
decision.shouldHide -> {
|
||||||
if (!decision.shouldResetCooldown) {
|
// Don't stamp the cooldown on a non-CLOSED hide (UNKNOWN/NOT_IN_CASE). Refreshing it
|
||||||
caseCoolDowns[cooldownKey] = now
|
// here would let a transient UNKNOWN (e.g. a brief out-of-case frame) suppress a
|
||||||
}
|
// genuine OPEN for the whole cooldown window. CLOSED still resets it above.
|
||||||
Event.PopupHide(now)
|
Event.PopupHide(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +199,48 @@ class PopUpReaction @Inject constructor(
|
|||||||
}
|
}
|
||||||
.setupCommonEventHandlers(TAG) { "popUpConnection" }
|
.setupCommonEventHandlers(TAG) { "popUpConnection" }
|
||||||
|
|
||||||
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection())
|
/**
|
||||||
|
* Backstop for case-open popups that never receive a CLOSED frame because the device left BLE
|
||||||
|
* range while the lid was open — otherwise the overlay lingers until manually dismissed (one of
|
||||||
|
* the symptoms in #598). A ticker re-checks the primary device's freshness; once a previously
|
||||||
|
* fresh OPEN broadcast goes stale past [CASE_OPEN_STALE_TIMEOUT] (no newer advertisement, or the
|
||||||
|
* device dropped to cache-only), a single Hide is emitted. The lid-driven [monitorCase] still
|
||||||
|
* handles the normal close; a redundant Hide here is harmless ([PopUpWindow.close] is idempotent).
|
||||||
|
*/
|
||||||
|
private fun monitorCaseStaleClose(): Flow<Event> = combine(
|
||||||
|
deviceMonitor.primaryDevice(),
|
||||||
|
staleCheckTicker(),
|
||||||
|
) { device, _ -> isCaseOpenBroadcastFresh(device) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.withPrevious()
|
||||||
|
.mapNotNull { (wasFresh, isFresh) ->
|
||||||
|
if (wasFresh == true && !isFresh) {
|
||||||
|
log(TAG) { "Case-open broadcast went stale, emitting Hide" }
|
||||||
|
Event.PopupHide(timeSource.now())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.setupCommonEventHandlers(TAG) { "popUpCaseStale" }
|
||||||
|
|
||||||
|
/** True while the primary device is eligible and currently advertising a fresh OPEN lid. */
|
||||||
|
internal fun isCaseOpenBroadcastFresh(device: PodDevice?): Boolean {
|
||||||
|
if (device?.reactions?.showPopUpOnCaseOpen != true) return false
|
||||||
|
if (device.caseLidState != DualApplePods.LidState.OPEN) return false
|
||||||
|
// Track BLE freshness specifically, not PodDevice.seenLastAt (which also counts AAP/cache):
|
||||||
|
// the lid is a BLE-only signal, so a live AAP socket must not keep a stale OPEN on screen.
|
||||||
|
val bleSeenLastAt = device.ble?.seenLastAt ?: return false
|
||||||
|
return Duration.between(bleSeenLastAt, timeSource.now()) <= CASE_OPEN_STALE_TIMEOUT
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun staleCheckTicker(): Flow<Unit> = flow {
|
||||||
|
while (true) {
|
||||||
|
emit(Unit)
|
||||||
|
delay(STALE_CHECK_INTERVAL.toMillis())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection(), monitorCaseStaleClose())
|
||||||
|
|
||||||
sealed class Event {
|
sealed class Event {
|
||||||
data class PopupShow(
|
data class PopupShow(
|
||||||
@@ -296,5 +344,11 @@ class PopUpReaction @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Reaction", "PopUp")
|
private val TAG = logTag("Reaction", "PopUp")
|
||||||
|
|
||||||
|
/** A case-open popup is force-dismissed once its OPEN broadcast hasn't refreshed for this long. */
|
||||||
|
private val CASE_OPEN_STALE_TIMEOUT: Duration = Duration.ofSeconds(4)
|
||||||
|
|
||||||
|
/** How often the stale-close backstop re-evaluates freshness. */
|
||||||
|
private val STALE_CHECK_INTERVAL: Duration = Duration.ofSeconds(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ import eu.darken.capod.monitor.core.PodDevice
|
|||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.apple.ble.getBatteryIcon
|
import eu.darken.capod.pods.core.apple.ble.getBatteryIcon
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PopUpContent(
|
fun PopUpContent(
|
||||||
@@ -115,7 +113,7 @@ private fun DualPodContent(device: PodDevice) {
|
|||||||
// Left pod
|
// Left pod
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.leftPodIcon,
|
iconRes = device.leftPodIcon,
|
||||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
batteryPercent = device.batteryLeft,
|
||||||
isCharging = device.isLeftPodCharging ?: false,
|
isCharging = device.isLeftPodCharging ?: false,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -124,7 +122,7 @@ private fun DualPodContent(device: PodDevice) {
|
|||||||
if (device.hasCase) {
|
if (device.hasCase) {
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.caseIcon,
|
iconRes = device.caseIcon,
|
||||||
batteryPercent = device.batteryCase.toBatteryFloat(),
|
batteryPercent = device.batteryCase,
|
||||||
isCharging = device.isCaseCharging ?: false,
|
isCharging = device.isCaseCharging ?: false,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -133,7 +131,7 @@ private fun DualPodContent(device: PodDevice) {
|
|||||||
// Right pod
|
// Right pod
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.rightPodIcon,
|
iconRes = device.rightPodIcon,
|
||||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
batteryPercent = device.batteryRight,
|
||||||
isCharging = device.isRightPodCharging ?: false,
|
isCharging = device.isRightPodCharging ?: false,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -144,7 +142,7 @@ private fun DualPodContent(device: PodDevice) {
|
|||||||
private fun SinglePodContent(device: PodDevice) {
|
private fun SinglePodContent(device: PodDevice) {
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.iconRes,
|
iconRes = device.iconRes,
|
||||||
batteryPercent = device.batteryHeadset.toBatteryFloat(),
|
batteryPercent = device.batteryHeadset,
|
||||||
isCharging = device.isHeadsetBeingCharged ?: false,
|
isCharging = device.isHeadsetBeingCharged ?: false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -157,7 +155,6 @@ private fun BatteryColumn(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val nullablePercent = batteryPercent.toBatteryOrNull()
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -184,14 +181,14 @@ private fun BatteryColumn(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = getBatteryIcon(nullablePercent),
|
imageVector = getBatteryIcon(batteryPercent),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, nullablePercent),
|
text = formatBatteryPercent(context, batteryPercent),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import eu.darken.capod.common.TimeSource
|
|||||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
import eu.darken.capod.common.datastore.valueBlocking
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||||
|
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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.uix.ViewModel4
|
import eu.darken.capod.common.uix.ViewModel4
|
||||||
@@ -23,14 +23,13 @@ import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
|
|||||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.take
|
|
||||||
import kotlinx.coroutines.flow.takeWhile
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
import kotlinx.coroutines.flow.toList
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -43,12 +42,14 @@ class TroubleShooterViewModel @Inject constructor(
|
|||||||
private val blePodMonitor: BlePodMonitor,
|
private val blePodMonitor: BlePodMonitor,
|
||||||
private val bleScanModeController: BleScanModeController,
|
private val bleScanModeController: BleScanModeController,
|
||||||
private val deviceMonitor: DeviceMonitor,
|
private val deviceMonitor: DeviceMonitor,
|
||||||
private val debugSettings: DebugSettings,
|
|
||||||
private val timeSource: TimeSource,
|
private val timeSource: TimeSource,
|
||||||
) : ViewModel4(dispatcherProvider) {
|
) : ViewModel4(dispatcherProvider) {
|
||||||
|
|
||||||
private val _bleState = MutableStateFlow<BleState>(BleState.Intro())
|
private val _bleState = MutableStateFlow<BleState>(BleState.Intro())
|
||||||
|
|
||||||
|
/** Guards against re-entrant runs (e.g. a double tap on "Try again"). */
|
||||||
|
private val runLock = Mutex()
|
||||||
|
|
||||||
data class State(val bleState: BleState)
|
data class State(val bleState: BleState)
|
||||||
|
|
||||||
val state = _bleState.map { State(it) }.asLiveState()
|
val state = _bleState.map { State(it) }.asLiveState()
|
||||||
@@ -84,166 +85,184 @@ class TroubleShooterViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun troubleShootBle() = launch(context = dispatcherProvider.IO) {
|
fun troubleShootBle() = launch(context = dispatcherProvider.IO) {
|
||||||
|
if (!runLock.tryLock()) {
|
||||||
|
log(TAG, WARN) { "troubleShootBle() ignored, a run is already in progress" }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
log(TAG, INFO) { "troubleShootBle()" }
|
log(TAG, INFO) { "troubleShootBle()" }
|
||||||
|
|
||||||
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
|
try {
|
||||||
run {
|
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
|
||||||
progress("Checking for headphones...")
|
// The combo under which supported headphones were detected (set in sweep 2).
|
||||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
var supportedCombo: BlePodMonitor.CompatOverride? = null
|
||||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
// Set only when the run reaches a terminal success. Persisted on exit; staying null
|
||||||
}
|
// (any failure, or cancellation) means we just clear the override, which restores the
|
||||||
if (mainDevice != null) {
|
// user's original — never-touched — settings.
|
||||||
success("Headphones found, nothing to troubleshoot.")
|
var comboToPersist: BlePodMonitor.CompatOverride? = null
|
||||||
return@override
|
try {
|
||||||
} else {
|
run {
|
||||||
progress("Headphones not detected.\n")
|
progress("Checking for headphones...")
|
||||||
}
|
if (findLiveBleHeadphones()) {
|
||||||
}
|
success("Headphones found, nothing to troubleshoot.")
|
||||||
|
return@override
|
||||||
|
} else {
|
||||||
|
progress("Headphones not detected.\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<BlePodSnapshot> =
|
val doScan: suspend (BlePodMonitor.CompatOverride, Boolean) -> Collection<BlePodSnapshot> =
|
||||||
{ hardwareFilteringDisabled,
|
{ combo, unfiltered ->
|
||||||
hardwareBatchingDisabled,
|
progress(
|
||||||
indirectCallback,
|
"SCAN - Settings: filteringDisabled=${combo.offloadedFilteringDisabled}, " +
|
||||||
unfiltered ->
|
"batchingDisabled=${combo.offloadedBatchingDisabled}, " +
|
||||||
val sb = StringBuilder("SCAN - Settings: ")
|
"indirectCallback=${combo.indirectCallback}, unfiltered=$unfiltered"
|
||||||
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
|
)
|
||||||
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
|
blePodMonitor.setCompatOverride(combo)
|
||||||
sb.append("indirectCallback=$indirectCallback, ")
|
blePodMonitor.setUnfilteredOverride(unfiltered)
|
||||||
sb.append("unfiltered=$unfiltered")
|
val devices = collectFreshDevices()
|
||||||
progress(sb.toString())
|
log(TAG) { "SCAN: Fresh BLE devices: $devices" }
|
||||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
|
if (devices.isNotEmpty()) {
|
||||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
|
progress("SCAN: Received data from ${devices.size} BLE devices")
|
||||||
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
|
} else {
|
||||||
debugSettings.showUnfiltered.valueBlocking = unfiltered
|
progress("SCAN: No data received")
|
||||||
|
}
|
||||||
|
devices
|
||||||
|
}
|
||||||
|
|
||||||
val start = timeSource.elapsedRealtime()
|
run {
|
||||||
val devices = withTimeoutOrNull(STEP_TIME) {
|
progress("Checking if we can receive BLE data at all.")
|
||||||
blePodMonitor.devices
|
val gotData = COMPAT_COMBOS.any { combo -> doScan(combo, true).isNotEmpty() }
|
||||||
.take(10)
|
if (!gotData) {
|
||||||
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
|
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
|
||||||
.toList()
|
return@override
|
||||||
.flatten()
|
}
|
||||||
.distinctBy { it.address }
|
}
|
||||||
} ?: emptyList()
|
|
||||||
log(TAG) { "SCAN: BLE Devices: $devices" }
|
progress("We received at least some BLE data.\n")
|
||||||
if (devices.isNotEmpty()) {
|
|
||||||
progress("SCAN: Received data from ${devices.size} BLE devices")
|
run {
|
||||||
devices
|
progress("Checking for supported headphones.")
|
||||||
} else {
|
supportedCombo = COMPAT_COMBOS.firstOrNull { combo ->
|
||||||
progress("SCAN: No data received")
|
doScan(combo, false).any { it !is UnknownSnapshotBle }
|
||||||
devices
|
}
|
||||||
|
if (supportedCombo == null) {
|
||||||
|
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
|
||||||
|
return@override
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progress("Found some headphones that are supported by CAPod.\n")
|
||||||
|
|
||||||
|
run {
|
||||||
|
progress("Checking for your headphones with new BLE settings...")
|
||||||
|
if (findLiveBleHeadphones()) {
|
||||||
|
comboToPersist = supportedCombo
|
||||||
|
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 = collectFreshDevices()
|
||||||
|
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
|
||||||
|
|
||||||
|
val candidate = otherDevices
|
||||||
|
.filter { it !is UnknownSnapshotBle }
|
||||||
|
.maxByOrNull { it.signalQuality }
|
||||||
|
|
||||||
|
if (candidate == null) {
|
||||||
|
failure(
|
||||||
|
"No supported headphones found near your device.",
|
||||||
|
BleState.Result.Failure.Type.HEADPHONES,
|
||||||
|
)
|
||||||
|
return@override
|
||||||
|
}
|
||||||
|
|
||||||
|
progress("Headphones found nearby, but not detected as yours.\n")
|
||||||
|
progress("Creating profile for closest headphones.")
|
||||||
|
log(TAG, INFO) { "Candidate is $candidate" }
|
||||||
|
|
||||||
|
profilesRepo.addProfile(
|
||||||
|
profile = AppleDeviceProfile(
|
||||||
|
label = context.getString(R.string.troubleshooter_title),
|
||||||
|
model = candidate.model,
|
||||||
|
),
|
||||||
|
addFirst = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (findLiveBleHeadphones()) {
|
||||||
|
comboToPersist = supportedCombo
|
||||||
|
success("Success! Detected your headphones.")
|
||||||
|
} else {
|
||||||
|
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
blePodMonitor.setUnfilteredOverride(false)
|
||||||
|
try {
|
||||||
|
// Persist the winning combo before dropping the override so the effective scan
|
||||||
|
// settings stay equal with no restart flicker. Done in its own try so the
|
||||||
|
// override is still cleared (restoring originals) even if a write throws.
|
||||||
|
comboToPersist?.let { persistCompat(it) }
|
||||||
|
} finally {
|
||||||
|
blePodMonitor.setCompatOverride(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
progress("We received at least some BLE data.\n")
|
|
||||||
|
|
||||||
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 }
|
|
||||||
.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@override
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mainDevice != null) {
|
|
||||||
success("Success! Detected your headphones.")
|
|
||||||
} else {
|
|
||||||
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
runLock.unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits up to [STEP_TIME] for the primary profile to be backed by a *fresh, live BLE*
|
||||||
|
* observation. A cached-only / AAP-only primary does not count — the troubleshooter is about
|
||||||
|
* whether BLE advertisements are actually reaching us. The freshness cutoff (snapshot seen at or
|
||||||
|
* after this call) means it reflects the currently-active scan settings and doesn't rely on the
|
||||||
|
* device cache having been cleared beforehand.
|
||||||
|
*/
|
||||||
|
private suspend fun findLiveBleHeadphones(): Boolean {
|
||||||
|
val threshold = timeSource.now()
|
||||||
|
return withTimeoutOrNull(STEP_TIME) {
|
||||||
|
deviceMonitor.primaryDevice().firstOrNull { device ->
|
||||||
|
device?.ble != null && (device.seenLastAt?.let { it >= threshold } == true)
|
||||||
|
} != null
|
||||||
|
} ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects BLE devices observed *after the current scan settings take effect*. Option changes
|
||||||
|
* restart the scan after a throttle, and [BlePodMonitor] keeps a 20s device cache, so without a
|
||||||
|
* freshness cutoff a stale observation from a previous combo could be mistaken for a "win".
|
||||||
|
*/
|
||||||
|
private suspend fun collectFreshDevices(): List<BlePodSnapshot> {
|
||||||
|
// Drop anything cached under a previous combo so it can't satisfy this attempt.
|
||||||
|
blePodMonitor.clearDeviceCache()
|
||||||
|
val freshThreshold = timeSource.now().plusMillis(SCAN_SETTLE_MS)
|
||||||
|
val start = timeSource.elapsedRealtime()
|
||||||
|
val collected = mutableListOf<BlePodSnapshot>()
|
||||||
|
// Accumulate as we go: a timeout must not discard what we already observed. toList() only
|
||||||
|
// returns once the flow completes, which a quiet channel may never do within the window.
|
||||||
|
withTimeoutOrNull(STEP_TIME) {
|
||||||
|
blePodMonitor.devices
|
||||||
|
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
|
||||||
|
.collect { snapshots -> collected.addAll(snapshots) }
|
||||||
|
}
|
||||||
|
return collected
|
||||||
|
.filter { it.seenLastAt >= freshThreshold }
|
||||||
|
.distinctBy { it.address }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistCompat(combo: BlePodMonitor.CompatOverride) {
|
||||||
|
generalSettings.isOffloadedFilteringDisabled.valueBlocking = combo.offloadedFilteringDisabled
|
||||||
|
generalSettings.isOffloadedBatchingDisabled.valueBlocking = combo.offloadedBatchingDisabled
|
||||||
|
generalSettings.useIndirectScanResultCallback.valueBlocking = combo.indirectCallback
|
||||||
|
}
|
||||||
|
|
||||||
sealed class BleState {
|
sealed class BleState {
|
||||||
class Intro : BleState()
|
class Intro : BleState()
|
||||||
|
|
||||||
@@ -294,6 +313,29 @@ class TroubleShooterViewModel @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
const val STEP_TIME = 10 * 1000L
|
const val STEP_TIME = 10 * 1000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grace period after switching compat settings before an observation counts as "fresh".
|
||||||
|
* Covers [BlePodMonitor]'s ~1s scan-option throttle plus the scanner restart.
|
||||||
|
*/
|
||||||
|
const val SCAN_SETTLE_MS = 1500L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compatibility combinations to probe, ordered fewest-disables-first so the first one that
|
||||||
|
* works (and gets persisted) is the *minimal* set of overrides — e.g. a phone that only
|
||||||
|
* needs batching disabled won't also get filtering disabled. Triple semantics:
|
||||||
|
* (offloadedFilteringDisabled, offloadedBatchingDisabled, indirectCallback).
|
||||||
|
*/
|
||||||
|
val COMPAT_COMBOS: List<BlePodMonitor.CompatOverride> = listOf(
|
||||||
|
BlePodMonitor.CompatOverride(false, false, false), // baseline (no overrides)
|
||||||
|
BlePodMonitor.CompatOverride(false, true, false), // batching only
|
||||||
|
BlePodMonitor.CompatOverride(true, false, false), // filtering only
|
||||||
|
BlePodMonitor.CompatOverride(false, false, true), // indirect callback only
|
||||||
|
BlePodMonitor.CompatOverride(false, true, true), // batching + indirect
|
||||||
|
BlePodMonitor.CompatOverride(true, false, true), // filtering + indirect
|
||||||
|
BlePodMonitor.CompatOverride(true, true, false), // filtering + batching
|
||||||
|
BlePodMonitor.CompatOverride(true, true, true), // everything
|
||||||
|
)
|
||||||
|
|
||||||
val TAG = logTag("TroubleShooter", "VM")
|
val TAG = logTag("TroubleShooter", "VM")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,6 @@
|
|||||||
<string name="upgrade_screen_restore_troubleshooting_msg">As u onlangs gekoop het, kan dit \'n oomblik neem vir Google Play om te sinkroniseer.</string>
|
<string name="upgrade_screen_restore_troubleshooting_msg">As u onlangs gekoop het, kan dit \'n oomblik neem vir Google Play om te sinkroniseer.</string>
|
||||||
<string name="upgrade_screen_restore_sync_patience_hint">Probeer weer oor \'n paar minute as u aankoop nie verskyn nie.</string>
|
<string name="upgrade_screen_restore_sync_patience_hint">Probeer weer oor \'n paar minute as u aankoop nie verskyn nie.</string>
|
||||||
<string name="upgrade_screen_restore_multiaccount_hint">Maak seker dat u met dieselfde Google-rekening aangemeld is wat vir die aankoop gebruik is.</string>
|
<string name="upgrade_screen_restore_multiaccount_hint">Maak seker dat u met dieselfde Google-rekening aangemeld is wat vir die aankoop gebruik is.</string>
|
||||||
<string name="settings_monitor_mode_label">Monitormodus</string>
|
|
||||||
<string name="settings_monitor_mode_description">Onder watter omstandighede hierdie toepassing Bluetooth-data monitor.</string>
|
|
||||||
<string name="settings_monitor_connected_notification_label">Ekstra kennisgewing</string>
|
<string name="settings_monitor_connected_notification_label">Ekstra kennisgewing</string>
|
||||||
<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_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_label">Hou kennisgewing ná ontkoppeling</string>
|
||||||
@@ -49,13 +47,11 @@
|
|||||||
<string name="settings_autopause_label">Outo-pouse</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_autopause_description">Pouseer oudio wanneer die toestel uit jou oor verwyder word.</string>
|
||||||
<string name="settings_autopplay_label">Outo-speel</string>
|
<string name="settings_autopplay_label">Outo-speel</string>
|
||||||
<string name="settings_autoplay_description">Begin oudio-terugspeel wanneer toestel gedra word.</string>
|
<string name="settings_autoplay_description">Hervat musiek wanneer jy jou pods weer dra, maar slegs as Outopouseer dit gestop het. Pouses wat jy self geaktiveer het (foon, AirPods-steel, slaap) bly gepouseer.</string>
|
||||||
|
<string name="settings_start_music_on_wear_label">Begin musiek by dra</string>
|
||||||
|
<string name="settings_start_music_on_wear_description">Probeer altyd musiek speel wanneer jy jou pods opsit, selfs na \'n handmatige pouseer.</string>
|
||||||
<string name="settings_eardetection_info_label">Nota oor oorbespeuring</string>
|
<string name="settings_eardetection_info_label">Nota oor oorbespeuring</string>
|
||||||
<string name="settings_eardetection_info_description">As oorbespeuring net vir een pod werk, is dit \'n Apple-beperking. Slegs die primêre pod (wat vir die mikrofoon gebruik word) word bespeur. Stel op Apple-toestelle op: Settings → Bluetooth → AirPods → Microphone.</string>
|
<string name="settings_eardetection_info_description">As oorbespeuring net vir een pod werk, is dit \'n Apple-beperking. Slegs die primêre pod (wat vir die mikrofoon gebruik word) word bespeur. Stel op Apple-toestelle op: Settings → Bluetooth → AirPods → Microphone.</string>
|
||||||
<string name="settings_fake_data_label">Vals data</string>
|
|
||||||
<string name="settings_fake_data_description">Wys vals data, d.w.s. simuleer toestelle wat nie bestaan nie.</string>
|
|
||||||
<string name="settings_debug_label">Ontfoutinstellings</string>
|
|
||||||
<string name="settings_debug_description">Addisionele instellings om probleme met die toepassing te help oplos.</string>
|
|
||||||
<string name="settings_signal_minimum_label">Minimum seingehalte</string>
|
<string name="settings_signal_minimum_label">Minimum seingehalte</string>
|
||||||
<string name="settings_signal_minimum_description">Die minimum seingehalte wat \'n toestel moet hê om as joune beskou te word.</string>
|
<string name="settings_signal_minimum_description">Die minimum seingehalte wat \'n toestel moet hê om as joune beskou te word.</string>
|
||||||
<string name="settings_autoconnect_label">Outo-koppel</string>
|
<string name="settings_autoconnect_label">Outo-koppel</string>
|
||||||
@@ -80,8 +76,6 @@
|
|||||||
<string name="settings_popup_connected_label">Wys verbindingsopspringer</string>
|
<string name="settings_popup_connected_label">Wys verbindingsopspringer</string>
|
||||||
<string name="settings_popup_connected_description">Wys \'n opspringer wanneer die toestel vir die eerste keer koppel.</string>
|
<string name="settings_popup_connected_description">Wys \'n opspringer wanneer die toestel vir die eerste keer koppel.</string>
|
||||||
<string name="settings_popup_info_not_in_app">Opspringvensters word slegs gewys as jy nie in die program is nie.</string>
|
<string name="settings_popup_info_not_in_app">Opspringvensters word slegs gewys as jy nie in die program is nie.</string>
|
||||||
<string name="settings_popup_warning_manual_mode">Opspringvensters benodig die monitor om te loop. Jou monitormodus is ingestel op \"Wanneer program oop is\", wat die monitor stop wanneer jy die program verlaat.</string>
|
|
||||||
<string name="general_fix_it_action">Herstel dit</string>
|
|
||||||
<string name="device_settings_experimental_title">Eksperimentele kenmerk</string>
|
<string name="device_settings_experimental_title">Eksperimentele kenmerk</string>
|
||||||
<string name="device_settings_experimental_description">Hierdie kenmerk is nog nie op alle toestelle getoets nie. As iets nie korrek werk nie, open asseblief \'n kwessie met \'n foutopsporingsloglêer.</string>
|
<string name="device_settings_experimental_description">Hierdie kenmerk is nog nie op alle toestelle getoets nie. As iets nie korrek werk nie, open asseblief \'n kwessie met \'n foutopsporingsloglêer.</string>
|
||||||
<string name="device_settings_experimental_action">Open Kwessiespoorder</string>
|
<string name="device_settings_experimental_action">Open Kwessiespoorder</string>
|
||||||
@@ -122,10 +116,6 @@
|
|||||||
<string name="settings_acknowledgements_label">Erkennings</string>
|
<string name="settings_acknowledgements_label">Erkennings</string>
|
||||||
<string name="settings_debug_autoreports_label">Outomatiese foutverslae</string>
|
<string name="settings_debug_autoreports_label">Outomatiese foutverslae</string>
|
||||||
<string name="settings_debug_autoreports_description">Rapporteer probleme outomaties, bv. besonderhede oor \'n toepassingstorting sodat ek kan uitvind hoe om dit reg te stel.</string>
|
<string name="settings_debug_autoreports_description">Rapporteer probleme outomaties, bv. besonderhede oor \'n toepassingstorting sodat ek kan uitvind hoe om dit reg te stel.</string>
|
||||||
<string name="settings_debug_mode_label">Ontfoutmodus</string>
|
|
||||||
<string name="settings_debug_mode_description">Wys addisionele inligting om probleme op te los.</string>
|
|
||||||
<string name="settings_blescanner_unfiltered_label">Ongefiltreerde BLE-data</string>
|
|
||||||
<string name="settings_blescanner_unfiltered_description">Verwyder enige filters van die BLE-skandeerder om alle uitgesaaide BLE-data te wys. Nuttig om ondersteuning vir nuwe koptelefoontipes by te voeg.</string>
|
|
||||||
<string name="settings_compatibility_mode_label">Verenigbaarheidsmodus</string>
|
<string name="settings_compatibility_mode_label">Verenigbaarheidsmodus</string>
|
||||||
<string name="settings_compatibility_mode_description">Deaktiveer optimalisasies om verenigbaarheid te verbeter. Probeer dit as jy geen data sien nie.</string>
|
<string name="settings_compatibility_mode_description">Deaktiveer optimalisasies om verenigbaarheid te verbeter. Probeer dit as jy geen data sien nie.</string>
|
||||||
<string name="help_translate_label">Vertaling</string>
|
<string name="help_translate_label">Vertaling</string>
|
||||||
@@ -137,7 +127,14 @@
|
|||||||
<string name="anc_widget_config_aap_required_hint">Hierdie widget vereis \'n direkte verbinding (AAP) met jou toestel. Maak seker jou toestel is gepaar en verbind in die program voor jy hierdie widget gebruik.</string>
|
<string name="anc_widget_config_aap_required_hint">Hierdie widget vereis \'n direkte verbinding (AAP) met jou toestel. Maak seker jou toestel is gepaar en verbind in die program voor jy hierdie widget gebruik.</string>
|
||||||
<string name="anc_widget_no_anc_support_label">Hierdie toestel ondersteun nie geraasbeheersing nie</string>
|
<string name="anc_widget_no_anc_support_label">Hierdie toestel ondersteun nie geraasbeheersing nie</string>
|
||||||
<string name="anc_widget_aap_not_connected_label">Nie verbind nie</string>
|
<string name="anc_widget_aap_not_connected_label">Nie verbind nie</string>
|
||||||
|
<string name="anc_widget_aap_not_connected_nearby_description">Naby maar nie gekoppel nie</string>
|
||||||
|
<string name="anc_widget_aap_not_connected_not_nearby_description">Nie naby nie</string>
|
||||||
<string name="anc_widget_aap_connecting_label">Verbind…</string>
|
<string name="anc_widget_aap_connecting_label">Verbind…</string>
|
||||||
|
<string name="tile_anc_label">Geraaskontrole</string>
|
||||||
|
<string name="tile_anc_subtitle_permission_required">Toestemming benodig</string>
|
||||||
|
<string name="tile_anc_subtitle_no_device">Geen toestel</string>
|
||||||
|
<string name="tile_anc_subtitle_no_anc_support">Geen geraaskontrole</string>
|
||||||
|
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth af</string>
|
||||||
<string name="widget_config_screen_title">Widget-konfigurasie</string>
|
<string name="widget_config_screen_title">Widget-konfigurasie</string>
|
||||||
<string name="widget_configuration_title">Kies toestel</string>
|
<string name="widget_configuration_title">Kies toestel</string>
|
||||||
<string name="widget_configuration_description">Kies watter toestelprofiel hierdie legstuk moet wys.</string>
|
<string name="widget_configuration_description">Kies watter toestelprofiel hierdie legstuk moet wys.</string>
|
||||||
@@ -220,9 +217,6 @@
|
|||||||
<string name="permission_required_title">Die volgende toestemming is vereis:</string>
|
<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_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="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_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>
|
|
||||||
<string name="settings_reaction_autoconnect_whenseen_label">Wanneer gesien</string>
|
<string name="settings_reaction_autoconnect_whenseen_label">Wanneer gesien</string>
|
||||||
<string name="settings_reaction_autoconnect_caseopen_label">Kassie is oop</string>
|
<string name="settings_reaction_autoconnect_caseopen_label">Kassie is oop</string>
|
||||||
<string name="settings_reaction_autoconnect_inear_label">In oor</string>
|
<string name="settings_reaction_autoconnect_inear_label">In oor</string>
|
||||||
@@ -254,6 +248,11 @@
|
|||||||
<string name="anc_mode_transparency">Deursigtigheid</string>
|
<string name="anc_mode_transparency">Deursigtigheid</string>
|
||||||
<string name="anc_mode_adaptive">Aanpasbaar</string>
|
<string name="anc_mode_adaptive">Aanpasbaar</string>
|
||||||
<string name="conversation_awareness_label">Gespreksbewustheid</string>
|
<string name="conversation_awareness_label">Gespreksbewustheid</string>
|
||||||
|
<string name="settings_conversation_action_label">Wanneer jy begin praat</string>
|
||||||
|
<string name="settings_conversation_action_nothing_label">Doen niks</string>
|
||||||
|
<string name="settings_conversation_action_lower_volume_label">Verlaag volume</string>
|
||||||
|
<string name="settings_conversation_action_pause_label">Pouseer media</string>
|
||||||
|
<string name="settings_conversation_volume_reduction_label">Volumevermindering</string>
|
||||||
<string name="signal_badge_ble_cd">BLE-skandering aktief</string>
|
<string name="signal_badge_ble_cd">BLE-skandering aktief</string>
|
||||||
<string name="signal_badge_key_irk_cd">Identiteit geverifieer</string>
|
<string name="signal_badge_key_irk_cd">Identiteit geverifieer</string>
|
||||||
<string name="signal_badge_key_encrypted_cd">Versleutelde verbinding</string>
|
<string name="signal_badge_key_encrypted_cd">Versleutelde verbinding</string>
|
||||||
@@ -322,7 +321,6 @@
|
|||||||
<string name="ui_theme_color_amber_label">Amber</string>
|
<string name="ui_theme_color_amber_label">Amber</string>
|
||||||
<string name="ui_theme_color_disabled_materialyou">Gebruik muurpapierkleure</string>
|
<string name="ui_theme_color_disabled_materialyou">Gebruik muurpapierkleure</string>
|
||||||
<!-- Settings categories -->
|
<!-- Settings categories -->
|
||||||
<string name="settings_category_monitoring_label">Monitering</string>
|
|
||||||
<string name="settings_category_appearance_label">Voorkoms</string>
|
<string name="settings_category_appearance_label">Voorkoms</string>
|
||||||
<!-- Unsaved changes dialog -->
|
<!-- Unsaved changes dialog -->
|
||||||
<string name="general_unsaved_changes_title">Ongestoorde veranderinge</string>
|
<string name="general_unsaved_changes_title">Ongestoorde veranderinge</string>
|
||||||
@@ -470,7 +468,7 @@
|
|||||||
<!-- New device settings -->
|
<!-- New device settings -->
|
||||||
<string name="device_settings_category_general_label">Algemeen</string>
|
<string name="device_settings_category_general_label">Algemeen</string>
|
||||||
<string name="device_settings_microphone_mode_label">Mikrofoon</string>
|
<string name="device_settings_microphone_mode_label">Mikrofoon</string>
|
||||||
<string name="device_settings_microphone_mode_description">Watter AirPod as mikrofoon gebruik word</string>
|
<string name="device_settings_microphone_mode_description">Watter oorfoon as mikrofoon gebruik word</string>
|
||||||
<string name="device_settings_microphone_mode_auto">Outomaties</string>
|
<string name="device_settings_microphone_mode_auto">Outomaties</string>
|
||||||
<string name="device_settings_microphone_mode_right">Regs</string>
|
<string name="device_settings_microphone_mode_right">Regs</string>
|
||||||
<string name="device_settings_microphone_mode_left">Links</string>
|
<string name="device_settings_microphone_mode_left">Links</string>
|
||||||
|
|||||||
@@ -40,8 +40,6 @@
|
|||||||
<string name="upgrade_screen_restore_troubleshooting_msg">በቅርቡ ካዘዙ፣ Google Play ለማመሳሰል ትንሽ ጊዜ ሊወስድ ይችላል።</string>
|
<string name="upgrade_screen_restore_troubleshooting_msg">በቅርቡ ካዘዙ፣ Google Play ለማመሳሰል ትንሽ ጊዜ ሊወስድ ይችላል።</string>
|
||||||
<string name="upgrade_screen_restore_sync_patience_hint">ግዢዎ ካልታየ ከጥቂት ደቂቃዎች በኋላ እንደገና ሞክር።</string>
|
<string name="upgrade_screen_restore_sync_patience_hint">ግዢዎ ካልታየ ከጥቂት ደቂቃዎች በኋላ እንደገና ሞክር።</string>
|
||||||
<string name="upgrade_screen_restore_multiaccount_hint">ለግዢው ጥቅም ላይ ከዋለው ጋር ተመሳሳይ Google መለያ ተጠቅመህ ገብተሃል ወይም ብለህ አረጋግጥ።</string>
|
<string name="upgrade_screen_restore_multiaccount_hint">ለግዢው ጥቅም ላይ ከዋለው ጋር ተመሳሳይ Google መለያ ተጠቅመህ ገብተሃል ወይም ብለህ አረጋግጥ።</string>
|
||||||
<string name="settings_monitor_mode_label">የክትትል ሁነታ</string>
|
|
||||||
<string name="settings_monitor_mode_description">ይህ መተግበሪያ የብሉቱዝ መረጃን የሚከታተልበት ሁኔታዎች።</string>
|
|
||||||
<string name="settings_monitor_connected_notification_label">ተጨማሪ ማሳወቂያ</string>
|
<string name="settings_monitor_connected_notification_label">ተጨማሪ ማሳወቂያ</string>
|
||||||
<string name="settings_monitor_connected_notification_description">አንድ መሣሪያ ሲገናኝ ተጨማሪ ማሳወቂያ ያሳያል። ይህ የ \"የመሣሪያ ሁኔታ\" ቻናልን በማሰናከል ቋሚውን \"ምንም መሣሪያዎች የሉም\" የሚለውን ማሳወቂያ እንዲደብቁ ያስችልዎታል።</string>
|
<string name="settings_monitor_connected_notification_description">አንድ መሣሪያ ሲገናኝ ተጨማሪ ማሳወቂያ ያሳያል። ይህ የ \"የመሣሪያ ሁኔታ\" ቻናልን በማሰናከል ቋሚውን \"ምንም መሣሪያዎች የሉም\" የሚለውን ማሳወቂያ እንዲደብቁ ያስችልዎታል።</string>
|
||||||
<string name="settings_keep_notification_after_disconnect_label">ከተነተቀ በኋላ ማሳወቂያውን ይያዙ</string>
|
<string name="settings_keep_notification_after_disconnect_label">ከተነተቀ በኋላ ማሳወቂያውን ይያዙ</string>
|
||||||
@@ -49,13 +47,11 @@
|
|||||||
<string name="settings_autopause_label">ራስ-ሰር ለአፍታ ማቆም</string>
|
<string name="settings_autopause_label">ራስ-ሰር ለአፍታ ማቆም</string>
|
||||||
<string name="settings_autopause_description">መሳሪያውን ከጆሮዎ ሲያወጡ ድምጽን ለአፍታ ያቁሙ።</string>
|
<string name="settings_autopause_description">መሳሪያውን ከጆሮዎ ሲያወጡ ድምጽን ለአፍታ ያቁሙ።</string>
|
||||||
<string name="settings_autopplay_label">ራስ-ሰር አጫውት</string>
|
<string name="settings_autopplay_label">ራስ-ሰር አጫውት</string>
|
||||||
<string name="settings_autoplay_description">መሳሪያው ሲለበስ የድምጽ መልሶ ማጫወት ይጀምሩ።</string>
|
<string name="settings_autoplay_description">ፖዶቹን እንደገና ሲያጠቁ ሙዚቃን ቀጥል፣ ግን ራስ-አቆም ካቆመው ብቻ። ራስዎ ያቆሙት (ስልክ፣ የ AirPods ቀጭኑ፣ ወይም እንቅልፍ) ቆሞ ይቆያል።</string>
|
||||||
|
<string name="settings_start_music_on_wear_label">ሲያጠቁ ሙዚቃ ጀምር</string>
|
||||||
|
<string name="settings_start_music_on_wear_description">ፖዶቹን ሲያጠቁ ሁልጊዜ ሙዚቃ ለማጫወት ይሞክራል፣ እጅ ሲቆም ካቆሙ በኋላ እንኳን።</string>
|
||||||
<string name="settings_eardetection_info_label">የጆሮ ማወቂያ ማስታወሻ</string>
|
<string name="settings_eardetection_info_label">የጆሮ ማወቂያ ማስታወሻ</string>
|
||||||
<string name="settings_eardetection_info_description">የጆሮ ማወቂያ ለአንድ ፖድ ብቻ የሚሰራ ከሆነ ይህ የApple ገደብ ነው። \"ዋና ፖድ\" (ለማይክሮፎን የሚጠቅም) ብቻ ይታወቃል። በApple መሳሪያዎች ላይ ያዋቅሩ: Settings → Bluetooth → AirPods → Microphone.</string>
|
<string name="settings_eardetection_info_description">የጆሮ ማወቂያ ለአንድ ፖድ ብቻ የሚሰራ ከሆነ ይህ የApple ገደብ ነው። \"ዋና ፖድ\" (ለማይክሮፎን የሚጠቅም) ብቻ ይታወቃል። በApple መሳሪያዎች ላይ ያዋቅሩ: Settings → Bluetooth → AirPods → Microphone.</string>
|
||||||
<string name="settings_fake_data_label">የውሸት መረጃ</string>
|
|
||||||
<string name="settings_fake_data_description">የውሸት መረጃ ያሳዩ፣ ማለትም የሌሉ መሳሪያዎችን አስመስለው ያሳዩ።</string>
|
|
||||||
<string name="settings_debug_label">የማረሚያ ቅንብሮች</string>
|
|
||||||
<string name="settings_debug_description">በመተግበሪያው ላይ ያሉ ችግሮችን ለመፍታት የሚረዱ ተጨማሪ ቅንብሮች።</string>
|
|
||||||
<string name="settings_signal_minimum_label">ዝቅተኛ የሲግናል ጥራት</string>
|
<string name="settings_signal_minimum_label">ዝቅተኛ የሲግናል ጥራት</string>
|
||||||
<string name="settings_signal_minimum_description">አንድ መሣሪያ የእርስዎ ተደርጎ እንዲቆጠር ሊኖረው የሚገባው ዝቅተኛው የሲግናል ጥራት።</string>
|
<string name="settings_signal_minimum_description">አንድ መሣሪያ የእርስዎ ተደርጎ እንዲቆጠር ሊኖረው የሚገባው ዝቅተኛው የሲግናል ጥራት።</string>
|
||||||
<string name="settings_autoconnect_label">ራስ-ሰር ግንኙነት</string>
|
<string name="settings_autoconnect_label">ራስ-ሰር ግንኙነት</string>
|
||||||
@@ -80,8 +76,6 @@
|
|||||||
<string name="settings_popup_connected_label">የግንኙነት ብቅ-ባይ አሳይ</string>
|
<string name="settings_popup_connected_label">የግንኙነት ብቅ-ባይ አሳይ</string>
|
||||||
<string name="settings_popup_connected_description">መሳሪያው ለመጀመሪያ ጊዜ ሲገናኝ ብቅ-ባይ አሳይ።</string>
|
<string name="settings_popup_connected_description">መሳሪያው ለመጀመሪያ ጊዜ ሲገናኝ ብቅ-ባይ አሳይ።</string>
|
||||||
<string name="settings_popup_info_not_in_app">ፖፕ-አፕ መልዕክቶች በፕሮግራሙ ውስጥ ሳታሉ ብቻ ይታያሉ።</string>
|
<string name="settings_popup_info_not_in_app">ፖፕ-አፕ መልዕክቶች በፕሮግራሙ ውስጥ ሳታሉ ብቻ ይታያሉ።</string>
|
||||||
<string name="settings_popup_warning_manual_mode">ፖፕ-አፕ መልዕክቶች ሞኒተሩ እየሮጠ እንዲሆን ይፈልጋሉ። የሞኒተር ሁነታዎ \"ፕሮግራሙ ሲከፈት\" ተዘጋጅቷል፣ ይህም ፕሮግራሙን ሲለቁ ሞኒተሩን ያቆማል።</string>
|
|
||||||
<string name="general_fix_it_action">አስተካክለው</string>
|
|
||||||
<string name="device_settings_experimental_title">ሙከራዊ ባህሪ</string>
|
<string name="device_settings_experimental_title">ሙከራዊ ባህሪ</string>
|
||||||
<string name="device_settings_experimental_description">ይህ ባህሪ በሁሉም መሳሪያዎች ላይ ገና አልተሞከረም። ሆኖም ካልሰራ፣ እባክዎ የዲበግ ምዝግብ ማስታወሻ ጨምረው ጉዳዩን ይክፈቱ።</string>
|
<string name="device_settings_experimental_description">ይህ ባህሪ በሁሉም መሳሪያዎች ላይ ገና አልተሞከረም። ሆኖም ካልሰራ፣ እባክዎ የዲበግ ምዝግብ ማስታወሻ ጨምረው ጉዳዩን ይክፈቱ።</string>
|
||||||
<string name="device_settings_experimental_action">የጉዳይ መከታተያ ክፈት</string>
|
<string name="device_settings_experimental_action">የጉዳይ መከታተያ ክፈት</string>
|
||||||
@@ -122,10 +116,6 @@
|
|||||||
<string name="settings_acknowledgements_label">ምስጋናዎች</string>
|
<string name="settings_acknowledgements_label">ምስጋናዎች</string>
|
||||||
<string name="settings_debug_autoreports_label">ራስ-ሰር የሳንካ ሪፖርቶች</string>
|
<string name="settings_debug_autoreports_label">ራስ-ሰር የሳንካ ሪፖርቶች</string>
|
||||||
<string name="settings_debug_autoreports_description">ችግሮችን በራስ-ሰር ሪፖርት ያደርጋል፣ ለምሳሌ የመተግበሪያ ብልሽት ዝርዝሮችን እንዴት ማስተካከል እንዳለብኝ እንድገነዘብ።</string>
|
<string name="settings_debug_autoreports_description">ችግሮችን በራስ-ሰር ሪፖርት ያደርጋል፣ ለምሳሌ የመተግበሪያ ብልሽት ዝርዝሮችን እንዴት ማስተካከል እንዳለብኝ እንድገነዘብ።</string>
|
||||||
<string name="settings_debug_mode_label">የማረሚያ ሁነታ</string>
|
|
||||||
<string name="settings_debug_mode_description">ችግሮችን ለመፍታት ተጨማሪ መረጃ ያሳዩ።</string>
|
|
||||||
<string name="settings_blescanner_unfiltered_label">ያልተጣራ የBLE መረጃ</string>
|
|
||||||
<string name="settings_blescanner_unfiltered_description">ሁሉንም የተላለፉ የBLE መረጃዎችን ለማሳየት ማንኛውንም ማጣሪያ ከBLE ቃኚው ያስወግዱ። ለአዲስ የጆሮ ማዳመጫ አይነቶች ድጋፍ ለመጨመር ጠቃሚ ነው።</string>
|
|
||||||
<string name="settings_compatibility_mode_label">የተኳኋኝነት ሁነታ</string>
|
<string name="settings_compatibility_mode_label">የተኳኋኝነት ሁነታ</string>
|
||||||
<string name="settings_compatibility_mode_description">ተኳኋኝነትን ለማሻሻል ማመቻቸቶችን አሰናክል። ምንም መረጃ ካላዩ ይህንን ይሞክሩ።</string>
|
<string name="settings_compatibility_mode_description">ተኳኋኝነትን ለማሻሻል ማመቻቸቶችን አሰናክል። ምንም መረጃ ካላዩ ይህንን ይሞክሩ።</string>
|
||||||
<string name="help_translate_label">ትርጉም</string>
|
<string name="help_translate_label">ትርጉም</string>
|
||||||
@@ -137,7 +127,14 @@
|
|||||||
<string name="anc_widget_config_aap_required_hint">ይህ ዊጄት ወደ መሳሪያዎ ቀጥታ ግንኙነት (AAP) ይፈልጋል። ይህን ዊጄት ከመጠቀምዎ በፊት መሳሪያዎ ጥምር ሆኖ በአፕ ውስጥ መገናኘቱን ያረጋግጡ።</string>
|
<string name="anc_widget_config_aap_required_hint">ይህ ዊጄት ወደ መሳሪያዎ ቀጥታ ግንኙነት (AAP) ይፈልጋል። ይህን ዊጄት ከመጠቀምዎ በፊት መሳሪያዎ ጥምር ሆኖ በአፕ ውስጥ መገናኘቱን ያረጋግጡ።</string>
|
||||||
<string name="anc_widget_no_anc_support_label">ይህ መሳሪያ የጫጫታ ቁጥጥርን አይደግፍም</string>
|
<string name="anc_widget_no_anc_support_label">ይህ መሳሪያ የጫጫታ ቁጥጥርን አይደግፍም</string>
|
||||||
<string name="anc_widget_aap_not_connected_label">አልተገናኘም</string>
|
<string name="anc_widget_aap_not_connected_label">አልተገናኘም</string>
|
||||||
|
<string name="anc_widget_aap_not_connected_nearby_description">አቅራቢያ ነው ግን አልተገናኘም</string>
|
||||||
|
<string name="anc_widget_aap_not_connected_not_nearby_description">አቅራቢያ የለም</string>
|
||||||
<string name="anc_widget_aap_connecting_label">በመገናኘት ላይ…</string>
|
<string name="anc_widget_aap_connecting_label">በመገናኘት ላይ…</string>
|
||||||
|
<string name="tile_anc_label">ጫጫታ ቁጥጥር</string>
|
||||||
|
<string name="tile_anc_subtitle_permission_required">ፈቃድ ያስፈልጋል</string>
|
||||||
|
<string name="tile_anc_subtitle_no_device">መሣሪያ የለም</string>
|
||||||
|
<string name="tile_anc_subtitle_no_anc_support">ጫጫታ ቁጥጥር የለም</string>
|
||||||
|
<string name="tile_anc_subtitle_bluetooth_off">ብሉቱዝ ጠፍቷል</string>
|
||||||
<string name="widget_config_screen_title">ዊጀት ውቅር</string>
|
<string name="widget_config_screen_title">ዊጀት ውቅር</string>
|
||||||
<string name="widget_configuration_title">መሣሪያ ይምረጡ</string>
|
<string name="widget_configuration_title">መሣሪያ ይምረጡ</string>
|
||||||
<string name="widget_configuration_description">ይህ መግብር የትኛውን የመሣሪያ መገለጫ ማሳየት እንዳለበት ይምረጡ።</string>
|
<string name="widget_configuration_description">ይህ መግብር የትኛውን የመሣሪያ መገለጫ ማሳየት እንዳለበት ይምረጡ።</string>
|
||||||
@@ -220,9 +217,6 @@
|
|||||||
<string name="permission_required_title">የሚከተለው ፈቃድ ያስፈልጋል:</string>
|
<string name="permission_required_title">የሚከተለው ፈቃድ ያስፈልጋል:</string>
|
||||||
<string name="permission_system_alert_window_label">የስርዓት ማስጠንቀቂያ መስኮት</string>
|
<string name="permission_system_alert_window_label">የስርዓት ማስጠንቀቂያ መስኮት</string>
|
||||||
<string name="permission_system_alert_window_description">\"ብቅ-ባይ አሳይ\" ባህሪውን ለማስቻል CAPod በሌሎች መተግበሪያዎች ላይ እንዲሳል ፍቀድ።</string>
|
<string name="permission_system_alert_window_description">\"ብቅ-ባይ አሳይ\" ባህሪውን ለማስቻል CAPod በሌሎች መተግበሪያዎች ላይ እንዲሳል ፍቀድ።</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>
|
|
||||||
<string name="settings_reaction_autoconnect_whenseen_label">ሲታይ</string>
|
<string name="settings_reaction_autoconnect_whenseen_label">ሲታይ</string>
|
||||||
<string name="settings_reaction_autoconnect_caseopen_label">ኬዝ ክፍት ነው</string>
|
<string name="settings_reaction_autoconnect_caseopen_label">ኬዝ ክፍት ነው</string>
|
||||||
<string name="settings_reaction_autoconnect_inear_label">በጆሮ ውስጥ</string>
|
<string name="settings_reaction_autoconnect_inear_label">በጆሮ ውስጥ</string>
|
||||||
@@ -254,6 +248,11 @@
|
|||||||
<string name="anc_mode_transparency">ግልጽነት</string>
|
<string name="anc_mode_transparency">ግልጽነት</string>
|
||||||
<string name="anc_mode_adaptive">መላሚ</string>
|
<string name="anc_mode_adaptive">መላሚ</string>
|
||||||
<string name="conversation_awareness_label">የውይይት ግንዛቤ</string>
|
<string name="conversation_awareness_label">የውይይት ግንዛቤ</string>
|
||||||
|
<string name="settings_conversation_action_label">መናገር ሲጀምሩ</string>
|
||||||
|
<string name="settings_conversation_action_nothing_label">ምንም አታድርግ</string>
|
||||||
|
<string name="settings_conversation_action_lower_volume_label">ድምፅ ዝቅ አድርግ</string>
|
||||||
|
<string name="settings_conversation_action_pause_label">ሚዲያ አቆም</string>
|
||||||
|
<string name="settings_conversation_volume_reduction_label">የድምፅ ቅናሽ</string>
|
||||||
<string name="signal_badge_ble_cd">BLE ቅኝት ንቁ ነው</string>
|
<string name="signal_badge_ble_cd">BLE ቅኝት ንቁ ነው</string>
|
||||||
<string name="signal_badge_key_irk_cd">ማንነት ተረጋግጧል</string>
|
<string name="signal_badge_key_irk_cd">ማንነት ተረጋግጧል</string>
|
||||||
<string name="signal_badge_key_encrypted_cd">ምስጢራዊ ግንኙነት</string>
|
<string name="signal_badge_key_encrypted_cd">ምስጢራዊ ግንኙነት</string>
|
||||||
@@ -322,7 +321,6 @@
|
|||||||
<string name="ui_theme_color_amber_label">ህብረ-ቀለማዊ</string>
|
<string name="ui_theme_color_amber_label">ህብረ-ቀለማዊ</string>
|
||||||
<string name="ui_theme_color_disabled_materialyou">የዳሰሳ ምስል ቀለሞችን ይጠቀማል</string>
|
<string name="ui_theme_color_disabled_materialyou">የዳሰሳ ምስል ቀለሞችን ይጠቀማል</string>
|
||||||
<!-- Settings categories -->
|
<!-- Settings categories -->
|
||||||
<string name="settings_category_monitoring_label">ክትትል</string>
|
|
||||||
<string name="settings_category_appearance_label">ገጽታ</string>
|
<string name="settings_category_appearance_label">ገጽታ</string>
|
||||||
<!-- Unsaved changes dialog -->
|
<!-- Unsaved changes dialog -->
|
||||||
<string name="general_unsaved_changes_title">ያልተቀመጡ ለውጦች</string>
|
<string name="general_unsaved_changes_title">ያልተቀመጡ ለውጦች</string>
|
||||||
@@ -470,7 +468,7 @@
|
|||||||
<!-- New device settings -->
|
<!-- New device settings -->
|
||||||
<string name="device_settings_category_general_label">አጠቃላይ</string>
|
<string name="device_settings_category_general_label">አጠቃላይ</string>
|
||||||
<string name="device_settings_microphone_mode_label">ማይክሮፎን</string>
|
<string name="device_settings_microphone_mode_label">ማይክሮፎን</string>
|
||||||
<string name="device_settings_microphone_mode_description">የትኛው AirPod እንደ ማይክሮፎን ንእ ላይ ይወላል</string>
|
<string name="device_settings_microphone_mode_description">የትኛው ኢርባድ እንደ ማይክሮፎን ጥቅም ላይ እንደሚውል</string>
|
||||||
<string name="device_settings_microphone_mode_auto">ራስ-ሰር</string>
|
<string name="device_settings_microphone_mode_auto">ራስ-ሰር</string>
|
||||||
<string name="device_settings_microphone_mode_right">ቀኝ</string>
|
<string name="device_settings_microphone_mode_right">ቀኝ</string>
|
||||||
<string name="device_settings_microphone_mode_left">ግራ</string>
|
<string name="device_settings_microphone_mode_left">ግራ</string>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user