mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
48
Commits
v5.1.1-rc0
...
v5.1.5-rc0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d41979499 | ||
|
|
985b73754e | ||
|
|
e4e4190523 | ||
|
|
ebfb034fdd | ||
|
|
8a81d354e6 | ||
|
|
ac0c4693dc | ||
|
|
4fd7a41c53 | ||
|
|
cc1caf47f0 | ||
|
|
8b9f953818 | ||
|
|
d541de8c38 | ||
|
|
2821da434d | ||
|
|
07c72dc86c | ||
|
|
7d93e772b4 | ||
|
|
82dc88faac | ||
|
|
06b41ec8eb | ||
|
|
acc6e92b2d | ||
|
|
39922df792 | ||
|
|
5a8c4b94ef | ||
|
|
0ffa87deea | ||
|
|
fc98db09a2 | ||
|
|
e1c0a702dd | ||
|
|
3f310016b1 | ||
|
|
d399a6d908 | ||
|
|
fb104cbe61 | ||
|
|
97cbca7a6e | ||
|
|
02278dab17 | ||
|
|
2240bd7ae5 | ||
|
|
bc4096cb57 | ||
|
|
5a0ba1b0bd | ||
|
|
d04abdf80c | ||
|
|
0609352cc9 | ||
|
|
abde0b7d6a | ||
|
|
b952aff38d | ||
|
|
c8f5ceda34 | ||
|
|
e6dbd2d660 | ||
|
|
228d7d1f0c | ||
|
|
67fead3589 | ||
|
|
d0aab583d2 | ||
|
|
367c8d28ff | ||
|
|
b04e1fe628 | ||
|
|
ea3ef1ff3b | ||
|
|
ff8b9cf6ed | ||
|
|
96bc3f5df7 | ||
|
|
1891a6d73a | ||
|
|
698415237d | ||
|
|
7ef672a11f | ||
|
|
f67e8aa7d1 | ||
|
|
bcb4bdff08 |
@@ -43,3 +43,4 @@ Detailed guidelines are in `.claude/rules/`:
|
||||
- `pull-requests.md` — PR title and description conventions
|
||||
- `agent-instructions.md` — Sub-agent delegation and critical thinking
|
||||
- `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens
|
||||
- `release.md` — Release workflow (`Release prepare` dispatch), inputs, channel mapping, rollback
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Release Process
|
||||
|
||||
Releases are cut via the **Release prepare** workflow (`.github/workflows/release-prepare.yml`). It bumps `version.properties` and `VERSION`, commits to `main`, tags `v<version>`, pushes atomically, and dispatches `release-tag.yml` which builds, signs, and uploads.
|
||||
|
||||
## Dispatch
|
||||
|
||||
```bash
|
||||
# Plan only — no commit, no tag, no push.
|
||||
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true
|
||||
|
||||
# Real cut.
|
||||
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=false
|
||||
```
|
||||
|
||||
After `dry_run=false`: Job 1 computes + writes the summary, then Job 2 immediately commits/tags/pushes (no env gate — cancel the run between Job 1 and Job 2 if the summary looks wrong; you have ~seconds). The tag push naturally triggers `release-tag.yml` (the App-token push fires `on: push:` workflows; only `GITHUB_TOKEN`-pushes are suppressed). `release-tag.yml` then runs `validate-tag` and the existing `release-github` (`foss-production` approval) + `release-gplay` (`gplay-production` approval) jobs — those are the two human checkpoints, matching the pre-migration UX.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Default | Notes |
|
||||
|---|---|---|
|
||||
| `bump_kind` | `build` | `build` \| `patch` \| `minor` \| `major` |
|
||||
| `version_type` | `keep-current` | Preserves current `rc`/`beta`. Set explicitly to switch. |
|
||||
| `version_override` | empty | e.g. `5.1.2-rc0`. Bypasses bump_kind/version_type. |
|
||||
| `expected_current` | empty | Optional: fail if `version.properties` ≠ this. Useful for tight coordination. |
|
||||
| `dry_run` | `true` | Default is plan-only. |
|
||||
|
||||
Bump rules: `build` increments build; `patch`/`minor`/`major` zero everything to the right of the bumped field. All numeric fields bounded `0..99` (the `versionCode` formula collapses at ≥100).
|
||||
|
||||
## Local
|
||||
|
||||
```bash
|
||||
./tools/release/bump.sh --mode=plan --bump-kind=build --version-type=keep-current
|
||||
./tools/release/bump.sh --mode=check
|
||||
bats tools/release/bump.bats
|
||||
```
|
||||
|
||||
## Channel mapping
|
||||
|
||||
| Tag suffix | FOSS APK | GitHub release | Fastlane lane | Play track | Rollout |
|
||||
|---|---|---|---|---|---|
|
||||
| `-beta*` | `assembleFossBeta` | pre-release | `beta` | `beta` | 10% |
|
||||
| `-rc*` (or anything else) | `assembleFossRelease` | full release | `production` | **`beta`** | 10% |
|
||||
|
||||
`lane :production` in `Fastfile` uploads to Play's **beta** track at 10% — manually promoted to production via Play Console.
|
||||
|
||||
## Rollback
|
||||
|
||||
| Stage reached | Steps |
|
||||
|---|---|
|
||||
| Bump on `main`, downstream not started | `git push origin :refs/tags/v<bad>`, `git revert <bump-sha>`, push |
|
||||
| GitHub release created | Above + `gh release delete v<bad> --yes --cleanup-tag` |
|
||||
| Play upload completed | Above + halt rollout in Play Console (or `bundle exec fastlane supply --track beta --rollout 0 --version-code <bad-code>`) |
|
||||
| Job 2 ran but downstream rejected at env approval | Treat as first row — bump+tag are public on `main` regardless of downstream outcome |
|
||||
|
||||
`bump.sh` enforces strict `versionCode` monotonicity, so re-using a code is impossible without manually editing `version.properties`.
|
||||
|
||||
## Auth setup
|
||||
|
||||
`release-prepare.yml` Job 2 uses a GitHub App token (not `GITHUB_TOKEN`) to push the bump commit and tag. The App identity is in the rulesets' bypass list, which is what allows the push to bypass branch protection + tag-creation restrictions.
|
||||
|
||||
Required org secrets (set on the d4rken-org organization, accessible to `capod`):
|
||||
|
||||
- `RELEASE_APP_CLIENT_ID` — Client ID of the `d4rken-org-releaser` GitHub App (visible on the App's settings page, format `Iv1.<hex>` or similar)
|
||||
- `RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines)
|
||||
|
||||
The App is installed on this repo and added as a bypass actor to:
|
||||
- The main-branch ruleset (PR + status check requirements)
|
||||
- The tag ruleset (creation restriction on `v*`)
|
||||
|
||||
Other apps in the org can reuse the same App + secrets — just install the App on each repo and add it to that repo's rulesets' bypass lists.
|
||||
|
||||
## Defense in depth
|
||||
|
||||
`release-tag.yml` includes `validate-tag` which: (1) regex-checks `github.ref_name`, (2) runs `bump.sh --mode=check`, (3) asserts the parsed name matches the tag. Manual `gh workflow run release-tag.yml --ref vfoo` or hand-pushed tags fail before any build.
|
||||
|
||||
## Stuck-dispatch recovery
|
||||
|
||||
If Job 2's atomic push lands but the natural `on: push:` trigger doesn't fire `release-tag.yml` (rare — would mean GitHub dropped the event), the tag is public but no pipeline runs. Re-dispatch manually: `gh workflow run release-tag.yml --ref v<new> -f dry_run=false`.
|
||||
@@ -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/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
|
||||
|
||||
```bash
|
||||
# Full run — iterates over ALL_LOCALES in batches of BATCH_SIZE.
|
||||
# The script prints "Locales: N | Batch size: B | Batches: ceil(N/B)" at startup.
|
||||
./fastlane/generate_screenshots.sh
|
||||
|
||||
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
|
||||
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
|
||||
# Use this for local iteration and PRs that touch screenshot content.
|
||||
./fastlane/generate_screenshots.sh --smoke
|
||||
|
||||
# Full run — all 68 locales. Use only when intending to upload to Play Store
|
||||
# (the non-smoke output is .gitignored and should not be committed).
|
||||
./fastlane/generate_screenshots.sh
|
||||
|
||||
# Copy into fastlane directories (run after generate)
|
||||
./fastlane/copy_screenshots.sh
|
||||
|
||||
@@ -48,23 +55,36 @@ ScreenshotContent.kt (mock data + composables)
|
||||
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
||||
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
|
||||
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
|
||||
5. Run the 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
|
||||
|
||||
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
|
||||
2. Run `generate_screenshots.sh`
|
||||
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/*/images/phoneScreenshots/` and get uploaded to Play Store
|
||||
2. Run `generate_screenshots.sh --smoke`
|
||||
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
|
||||
|
||||
## After UI Changes
|
||||
|
||||
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate:
|
||||
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate the smoke set:
|
||||
|
||||
```bash
|
||||
./fastlane/generate_screenshots.sh
|
||||
./fastlane/generate_screenshots.sh --smoke
|
||||
./fastlane/copy_screenshots.sh --clean
|
||||
```
|
||||
|
||||
## Refreshing all locales on Play Store
|
||||
|
||||
Periodic, manual operation — not per-PR:
|
||||
|
||||
```bash
|
||||
./fastlane/generate_screenshots.sh # full, ~30 min, 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
|
||||
|
||||
- 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)
|
||||
|
||||
@@ -30,13 +30,4 @@ runs:
|
||||
~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-caches-
|
||||
|
||||
- name: Cache Android Global Build-Cache
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.android/build-cache
|
||||
key: ${{ runner.os }}-android-build-cache-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-android-build-cache-
|
||||
${{ runner.os }}-gradle-caches-
|
||||
@@ -3,6 +3,9 @@ name: Code tests & eval
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- VERSION
|
||||
- version.properties
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
@@ -78,4 +81,27 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate metadata lengths
|
||||
run: bash fastlane/check_metadata_length.sh
|
||||
run: bash fastlane/check_metadata_length.sh
|
||||
|
||||
check-release-tooling:
|
||||
name: Release tooling
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install bats and shellcheck
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y bats shellcheck
|
||||
|
||||
- name: Shellcheck bump.sh
|
||||
run: shellcheck tools/release/bump.sh
|
||||
|
||||
- name: Run bump.sh unit tests
|
||||
run: bats tools/release/bump.bats
|
||||
|
||||
- name: Verify version.properties + VERSION are consistent
|
||||
run: ./tools/release/bump.sh --mode=check
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- VERSION
|
||||
- version.properties
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Deploy GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- _config.yml
|
||||
- _layouts/**
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
- PRIVACY_POLICY.md
|
||||
- CNAME
|
||||
- .github/workflows/pages.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Jekyll site
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d #v6.0.0
|
||||
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 #v1.0.13
|
||||
with:
|
||||
source: ./
|
||||
destination: ./_site
|
||||
env:
|
||||
JEKYLL_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Sanity-check Jekyll output
|
||||
run: test -f _site/index.html && test -f _site/CNAME
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 #v5.0.0
|
||||
with:
|
||||
path: ./_site
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 #v5.0.0
|
||||
@@ -0,0 +1,235 @@
|
||||
name: Release prepare
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump_kind:
|
||||
description: 'How to bump the version (ignored if version_override is set)'
|
||||
type: choice
|
||||
options: [build, patch, minor, major]
|
||||
default: build
|
||||
version_type:
|
||||
description: 'Channel for the new version (keep-current preserves current type)'
|
||||
type: choice
|
||||
options: [keep-current, rc, beta]
|
||||
default: keep-current
|
||||
version_override:
|
||||
description: 'Explicit version, e.g. 5.1.2-rc0 (overrides bump_kind/version_type)'
|
||||
type: string
|
||||
default: ''
|
||||
expected_current:
|
||||
description: 'Optional safety check: fail if current version.properties does not match (e.g. 5.1.1-rc0)'
|
||||
type: string
|
||||
default: ''
|
||||
dry_run:
|
||||
description: 'When true: compute and validate only. When false: commit, tag, push, dispatch release-tag.yml.'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-prepare-main
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
compute-and-validate:
|
||||
name: Compute and validate
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
new_name: ${{ steps.plan.outputs.new_name }}
|
||||
new_code: ${{ steps.plan.outputs.new_code }}
|
||||
current_name: ${{ steps.plan.outputs.current_name }}
|
||||
env:
|
||||
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
|
||||
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
|
||||
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
|
||||
INPUT_EXPECTED_CURRENT: ${{ inputs.expected_current }}
|
||||
steps:
|
||||
- name: Guard ref must be main
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Must dispatch from main, got ${GITHUB_REF}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Compute and validate
|
||||
id: plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=("--mode=plan")
|
||||
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
|
||||
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
|
||||
else
|
||||
args+=("--bump-kind=${INPUT_BUMP_KIND}")
|
||||
args+=("--version-type=${INPUT_VERSION_TYPE}")
|
||||
fi
|
||||
if [[ -n "${INPUT_EXPECTED_CURRENT}" ]]; then
|
||||
args+=("--expected-current=${INPUT_EXPECTED_CURRENT}")
|
||||
fi
|
||||
./tools/release/bump.sh "${args[@]}" | tee plan.txt
|
||||
{
|
||||
grep -E '^current_name=' plan.txt
|
||||
grep -E '^new_name=' plan.txt
|
||||
grep -E '^new_code=' plan.txt
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Tag collision check (local + remote)
|
||||
env:
|
||||
NEW_NAME: ${{ steps.plan.outputs.new_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
|
||||
echo "Local tag v${NEW_NAME} already exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
|
||||
echo "Remote tag v${NEW_NAME} already exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Write step summary
|
||||
env:
|
||||
CURRENT_NAME: ${{ steps.plan.outputs.current_name }}
|
||||
NEW_NAME: ${{ steps.plan.outputs.new_name }}
|
||||
NEW_CODE: ${{ steps.plan.outputs.new_code }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "## Release plan"
|
||||
echo
|
||||
echo "| | |"
|
||||
echo "|---|---|"
|
||||
echo "| Current | \`${CURRENT_NAME}\` |"
|
||||
echo "| New | \`${NEW_NAME}\` (code ${NEW_CODE}) |"
|
||||
echo "| Tag | \`v${NEW_NAME}\` |"
|
||||
echo "| Dry run | \`${DRY_RUN}\` |"
|
||||
echo
|
||||
echo "### bump.sh output"
|
||||
echo
|
||||
echo '```'
|
||||
cat plan.txt
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
push-and-dispatch:
|
||||
name: Push and dispatch
|
||||
needs: compute-and-validate
|
||||
if: ${{ !inputs.dry_run }}
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
|
||||
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
|
||||
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
|
||||
NEW_NAME: ${{ needs.compute-and-validate.outputs.new_name }}
|
||||
NEW_CODE: ${{ needs.compute-and-validate.outputs.new_code }}
|
||||
CURRENT_NAME_AT_PLAN: ${{ needs.compute-and-validate.outputs.current_name }}
|
||||
steps:
|
||||
- name: Mint App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
|
||||
with:
|
||||
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Resolve bot identity
|
||||
id: bot
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
user_id=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id)
|
||||
echo "user_name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT"
|
||||
echo "user_email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout main with credentials
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Verify state still matches plan from Job 1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
|
||||
|
||||
- name: Re-check tag collision (state may have moved between jobs)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
|
||||
echo "Local tag v${NEW_NAME} already exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
|
||||
echo "Remote tag v${NEW_NAME} already exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Apply bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=("--mode=write" "--expected-current=${CURRENT_NAME_AT_PLAN}")
|
||||
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
|
||||
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
|
||||
else
|
||||
args+=("--bump-kind=${INPUT_BUMP_KIND}")
|
||||
args+=("--version-type=${INPUT_VERSION_TYPE}")
|
||||
fi
|
||||
./tools/release/bump.sh "${args[@]}"
|
||||
|
||||
- name: Verify post-write state matches plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./tools/release/bump.sh --mode=check --expected-current="${NEW_NAME}"
|
||||
|
||||
- name: Configure git identity
|
||||
env:
|
||||
BOT_USER_NAME: ${{ steps.bot.outputs.user_name }}
|
||||
BOT_USER_EMAIL: ${{ steps.bot.outputs.user_email }}
|
||||
run: |
|
||||
git config user.name "${BOT_USER_NAME}"
|
||||
git config user.email "${BOT_USER_EMAIL}"
|
||||
|
||||
- name: Commit and tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add version.properties VERSION
|
||||
git commit -m "Release: ${NEW_NAME}"
|
||||
git tag -a "v${NEW_NAME}" -m "Release v${NEW_NAME}"
|
||||
|
||||
- name: Atomic push (commit + tag)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git push --atomic origin "HEAD:refs/heads/main" "refs/tags/v${NEW_NAME}"
|
||||
|
||||
- name: Write step summary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "## Released"
|
||||
echo
|
||||
echo "| | |"
|
||||
echo "|---|---|"
|
||||
echo "| Tag | \`v${NEW_NAME}\` |"
|
||||
echo "| Version code | \`${NEW_CODE}\` |"
|
||||
echo "| Bump commit | on \`main\` |"
|
||||
echo "| Downstream | dispatched \`release-tag.yml\` |"
|
||||
echo
|
||||
echo "Watch the [Tagged releases](../../actions/workflows/release-tag.yml) workflow for the build + upload."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -14,11 +14,54 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate-tag:
|
||||
name: Validate tag
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check tag-name format
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${REF_NAME}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}-(rc|beta)[0-9]{1,2}$ ]]; then
|
||||
echo "Tag '${REF_NAME}' does not match v<M.m.p-(rc|beta)n>" >&2
|
||||
echo "Releases must be cut via the 'Release prepare' workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify version.properties matches tag
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Strip leading 'v' to get the bare version name.
|
||||
tag_name="${REF_NAME#v}"
|
||||
# bump.sh in check mode emits current_name=...
|
||||
parsed=$(./tools/release/bump.sh --mode=check)
|
||||
current_name=$(echo "$parsed" | grep -E '^current_name=' | cut -d= -f2)
|
||||
if [[ "$current_name" != "$tag_name" ]]; then
|
||||
echo "Tag '${REF_NAME}' does not match version.properties name '$current_name'" >&2
|
||||
echo "version.properties + VERSION must equal the tag — releases must be cut via 'Release prepare'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release-github:
|
||||
needs: validate-tag
|
||||
name: Create GitHub release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
runs-on: ubuntu-22.04
|
||||
environment: foss-production
|
||||
steps:
|
||||
@@ -61,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Create pre-release
|
||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ github.ref_name }}
|
||||
@@ -73,7 +116,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ github.ref_name }}
|
||||
@@ -83,10 +126,19 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger GitHub Pages deployment
|
||||
if: "!(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh workflow run pages.yml --ref main
|
||||
|
||||
release-gplay:
|
||||
needs: validate-tag
|
||||
name: Create Google Play release
|
||||
runs-on: ubuntu-22.04
|
||||
environment: gplay-production
|
||||
env:
|
||||
BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile
|
||||
steps:
|
||||
- name: Decode Keystore
|
||||
env:
|
||||
@@ -122,6 +174,10 @@ jobs:
|
||||
with:
|
||||
ruby-version: 3.3.6
|
||||
bundler-cache: true
|
||||
working-directory: fastlane
|
||||
|
||||
- name: Verify fastlane Bundler wiring
|
||||
run: bundle exec fastlane --version
|
||||
|
||||
- name: Assemble beta and upload to Google Play
|
||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
|
||||
+14
-1
@@ -17,5 +17,18 @@
|
||||
.kotlin
|
||||
# Screenshot test reference images (ephemeral, regenerated on demand)
|
||||
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
|
||||
protocol-research/
|
||||
protocol-research/
|
||||
_site/
|
||||
.jekyll-cache/
|
||||
vendor/bundle/
|
||||
@@ -20,9 +20,6 @@ exclude:
|
||||
- buildSrc
|
||||
- gradle/wrapper
|
||||
- fastlane
|
||||
- Gemfile
|
||||
- Gemfile.lock
|
||||
- crowdin*
|
||||
- app
|
||||
- app-common
|
||||
- CONTRIBUTING.md
|
||||
|
||||
@@ -130,6 +130,7 @@ android {
|
||||
//noinspection WrongGradleMethod
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "4g"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.preview.MOCK_NOW
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
@@ -37,16 +38,25 @@ import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
|
||||
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
||||
|
||||
@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(
|
||||
state = OverviewViewModel.State(
|
||||
now = MOCK_NOW,
|
||||
permissions = emptySet(),
|
||||
devices = listOf(
|
||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
),
|
||||
devices = devices,
|
||||
isDebugMode = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(
|
||||
@@ -83,19 +93,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
|
||||
|
||||
@Composable
|
||||
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(
|
||||
state = DeviceProfileCreationViewModel.State(
|
||||
isEditMode = false,
|
||||
name = "",
|
||||
name = "My AirPods Pro",
|
||||
nameError = null,
|
||||
selectedModel = null,
|
||||
selectedModel = PodModel.AIRPODS_PRO2,
|
||||
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
||||
identityKey = null,
|
||||
encryptionKey = null,
|
||||
selectedDevice = null,
|
||||
bondedDeviceItems = emptyList(),
|
||||
selectedDevice = pairedAirPods,
|
||||
bondedDeviceItems = bondedItems,
|
||||
minimumSignalQuality = 0.15f,
|
||||
canSave = false,
|
||||
canSave = true,
|
||||
),
|
||||
onBack = {},
|
||||
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)
|
||||
@Composable
|
||||
private fun PreviewDashboardDark() = DashboardContent()
|
||||
private fun PreviewDashboardDark() = DashboardContent(showAap = true)
|
||||
|
||||
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
|
||||
@Composable
|
||||
|
||||
@@ -141,6 +141,20 @@
|
||||
android:name=".monitor.core.worker.MonitorService"
|
||||
android:foregroundServiceType="connectedDevice"
|
||||
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>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
package eu.darken.capod
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
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.logging.LogCatLogger
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
@@ -22,7 +18,9 @@ import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
|
||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -30,7 +28,6 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@HiltAndroidApp
|
||||
open class App : Application() {
|
||||
@@ -45,19 +42,19 @@ open class App : Application() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
|
||||
|
||||
var foregroundExceptionHandled = false
|
||||
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
if (throwable.isForegroundServiceTimingException() && !foregroundExceptionHandled) {
|
||||
foregroundExceptionHandled = true
|
||||
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
||||
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable)
|
||||
Looper.loop()
|
||||
return@setDefaultUncaughtExceptionHandler
|
||||
}
|
||||
log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" }
|
||||
if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1)
|
||||
}
|
||||
Thread.setDefaultUncaughtExceptionHandler(
|
||||
CapodUncaughtExceptionHandler(
|
||||
previousHandler = oldHandler,
|
||||
cancelBeforeDelegate = { throwable ->
|
||||
// Best-effort shutdown: the system handler may terminate the process immediately,
|
||||
// but cancellation can still close sockets if it gets a scheduling window.
|
||||
if (::appScope.isInitialized) {
|
||||
appScope.cancel(CancellationException("Uncaught exception", throwable))
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
autoReporting.setup(this)
|
||||
|
||||
@@ -86,14 +83,5 @@ open class App : Application() {
|
||||
|
||||
companion object {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AppForegroundState @Inject constructor(
|
||||
@AppScope appScope: CoroutineScope,
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
) {
|
||||
|
||||
val isForeground: StateFlow<Boolean> = callbackFlow {
|
||||
val lifecycle = runCatching { ProcessLifecycleOwner.get().lifecycle }
|
||||
.getOrElse {
|
||||
log(TAG, WARN) { "Failed to access process lifecycle, assuming background: ${it.asLog()}" }
|
||||
trySend(false)
|
||||
close()
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
trySend(safeCurrentForegroundState())
|
||||
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> trySend(true)
|
||||
Lifecycle.Event.ON_STOP -> trySend(false)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
runCatching { lifecycle.addObserver(observer) }
|
||||
.onFailure {
|
||||
log(TAG, WARN) { "Failed to observe process lifecycle, assuming background: ${it.asLog()}" }
|
||||
trySend(false)
|
||||
close()
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
runCatching { lifecycle.removeObserver(observer) }
|
||||
.onFailure { log(TAG, WARN) { "Failed to remove process lifecycle observer: ${it.asLog()}" } }
|
||||
}
|
||||
}
|
||||
.flowOn(dispatcherProvider.MainImmediate)
|
||||
.catch {
|
||||
log(TAG, WARN) { "Foreground state flow failed, assuming background: ${it.asLog()}" }
|
||||
emit(false)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { log(TAG) { "isForeground=$it" } }
|
||||
.stateIn(
|
||||
scope = appScope,
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L),
|
||||
initialValue = safeCurrentForegroundState(),
|
||||
)
|
||||
|
||||
private fun safeCurrentForegroundState(): Boolean = runCatching {
|
||||
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}.getOrElse {
|
||||
log(TAG, WARN) { "Failed to read process lifecycle state, assuming background: ${it.asLog()}" }
|
||||
false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("App", "ForegroundState")
|
||||
}
|
||||
}
|
||||
@@ -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.logTag
|
||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -113,22 +114,7 @@ class BleScanner @Inject constructor(
|
||||
null
|
||||
}
|
||||
|
||||
val flushJob = if (!disableDirectScanCallback) {
|
||||
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
|
||||
}
|
||||
var flushJob: Job? = null
|
||||
|
||||
val filterList = when {
|
||||
useOffloadedFiltering -> filters.toList()
|
||||
@@ -167,26 +153,55 @@ class BleScanner @Inject constructor(
|
||||
setReportDelay(delay)
|
||||
}.build()
|
||||
|
||||
if (disableDirectScanCallback) {
|
||||
val callbackIntent = createStartIntent()
|
||||
log(TAG) {
|
||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
|
||||
try {
|
||||
if (disableDirectScanCallback) {
|
||||
val callbackIntent = createStartIntent()
|
||||
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)
|
||||
} else {
|
||||
log(TAG) {
|
||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
|
||||
}
|
||||
scanner.startScan(filterList, scanSettings, callback)
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "startScan() denied: ${e.message}" }
|
||||
forwarderConsumer?.cancel()
|
||||
close(e)
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
forwarderConsumer?.cancel()
|
||||
flushJob?.cancel()
|
||||
if (disableDirectScanCallback) {
|
||||
scanner.stopScan(createStopIntent())
|
||||
} else {
|
||||
scanner.stopScan(callback)
|
||||
try {
|
||||
if (disableDirectScanCallback) {
|
||||
scanner.stopScan(createStopIntent())
|
||||
} else {
|
||||
scanner.stopScan(callback)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "stopScan() denied: ${e.message}" }
|
||||
}
|
||||
log(TAG) { "BleScanner stopped" }
|
||||
}
|
||||
|
||||
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
|
||||
import java.time.Instant
|
||||
|
||||
data class BluetoothDevice2(
|
||||
internal val internal: BluetoothDevice,
|
||||
val address: BluetoothAddress,
|
||||
val name: String?,
|
||||
val seenFirstAt: Instant,
|
||||
) {
|
||||
val address: BluetoothAddress
|
||||
get() = internal.address
|
||||
|
||||
val name: String?
|
||||
get() = internal.name
|
||||
}
|
||||
internal val internal: BluetoothDevice? = null,
|
||||
)
|
||||
|
||||
@@ -255,6 +255,8 @@ class BluetoothManager2 @Inject constructor(
|
||||
}
|
||||
.map { device ->
|
||||
BluetoothDevice2(
|
||||
address = device.address,
|
||||
name = device.name,
|
||||
internal = device,
|
||||
seenFirstAt = seenDevicesLock.withLock {
|
||||
seenDevicesCache[device.address] ?: run {
|
||||
@@ -304,6 +306,8 @@ class BluetoothManager2 @Inject constructor(
|
||||
val wrappedDevices = rawDevices.map { device ->
|
||||
|
||||
BluetoothDevice2(
|
||||
address = device.address,
|
||||
name = device.name,
|
||||
internal = device,
|
||||
seenFirstAt = seenDevicesLock.withLock {
|
||||
seenDevicesCache[device.address] ?: run {
|
||||
@@ -318,6 +322,74 @@ class BluetoothManager2 @Inject constructor(
|
||||
emit(wrappedDevices)
|
||||
}
|
||||
|
||||
@android.annotation.SuppressLint("MissingPermission")
|
||||
private fun queryBondedAddresses(): Set<BluetoothAddress> =
|
||||
adapter?.bondedDevices?.map { it.address }?.toSet() ?: emptySet()
|
||||
|
||||
val bondedDeviceAddresses: Flow<Set<BluetoothAddress>> = callbackFlow {
|
||||
fun sendBondedAddresses(): Boolean = try {
|
||||
trySend(queryBondedAddresses())
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Error querying bonded device addresses: $e" }
|
||||
trySend(emptySet())
|
||||
close(e)
|
||||
false
|
||||
}
|
||||
|
||||
if (!sendBondedAddresses()) return@callbackFlow
|
||||
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != BluetoothDevice.ACTION_BOND_STATE_CHANGED) return
|
||||
sendBondedAddresses()
|
||||
}
|
||||
}
|
||||
try {
|
||||
context.registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED))
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Failed to register bond-state receiver: $e" }
|
||||
close(e)
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
try {
|
||||
context.unregisterReceiver(receiver)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Error unregistering bond-state receiver: $e" }
|
||||
}
|
||||
}
|
||||
}
|
||||
.retryWhen { cause, attempt ->
|
||||
log(TAG, WARN) { "bondedDeviceAddresses Flow failed (attempt ${attempt + 1}): $cause" }
|
||||
when {
|
||||
cause is SecurityException -> {
|
||||
delay(3_000L)
|
||||
true
|
||||
}
|
||||
attempt < 3 -> {
|
||||
delay(1000 * (attempt + 1))
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.catch { e ->
|
||||
log(TAG, ERROR) { "bondedDeviceAddresses Flow failed after retries: $e" }
|
||||
emit(emptySet())
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.setupCommonEventHandlers(TAG) { "bondedDeviceAddresses" }
|
||||
.stateIn(
|
||||
scope = appScope + dispatcherProvider.IO,
|
||||
started = SharingStarted.WhileSubscribed(
|
||||
stopTimeoutMillis = 5_000L,
|
||||
replayExpirationMillis = 0L,
|
||||
),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
private var _isNudgeAvailable: Boolean = true
|
||||
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
|
||||
|
||||
@@ -340,9 +412,10 @@ class BluetoothManager2 @Inject constructor(
|
||||
@android.annotation.SuppressLint("MissingPermission")
|
||||
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
||||
return try {
|
||||
val target = device.internal ?: return false
|
||||
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
||||
.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" }
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
@@ -356,11 +429,12 @@ class BluetoothManager2 @Inject constructor(
|
||||
try {
|
||||
log(TAG) { "Nudging Android connection to $device" }
|
||||
|
||||
val target = device.internal ?: return@map false
|
||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||
"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" }
|
||||
accepted
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class ScannerMode(
|
||||
val identifier: String,
|
||||
@StringRes val labelRes: Int
|
||||
) {
|
||||
@SerialName("scanner.mode.lowpower") LOW_POWER(
|
||||
"scanner.mode.lowpower",
|
||||
R.string.settings_scanner_mode_lowpower_label
|
||||
),
|
||||
@SerialName("scanner.mode.balanced") BALANCED(
|
||||
"scanner.mode.balanced",
|
||||
R.string.settings_scanner_mode_balanced_label
|
||||
),
|
||||
@SerialName("scanner.mode.lowlatency") LOW_LATENCY(
|
||||
"scanner.mode.lowlatency",
|
||||
R.string.settings_scanner_mode_lowlatency_label
|
||||
),
|
||||
}
|
||||
enum class ScannerMode {
|
||||
@SerialName("scanner.mode.lowpower") LOW_POWER,
|
||||
@SerialName("scanner.mode.balanced") BALANCED,
|
||||
@SerialName("scanner.mode.lowlatency") LOW_LATENCY,
|
||||
}
|
||||
|
||||
@@ -180,18 +180,21 @@ object MockPodDataProvider {
|
||||
|
||||
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-mixed",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProMixed(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-keys",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProWithKeys(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-aap",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProWithKeys(),
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
@@ -227,12 +230,14 @@ object MockPodDataProvider {
|
||||
|
||||
fun singlePodMonitored(): PodDevice = PodDevice(
|
||||
profileId = "preview-single",
|
||||
label = "AirPods Max",
|
||||
ble = airPodsMax(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
||||
profileId = "preview-single-aap",
|
||||
label = "AirPods Max",
|
||||
ble = airPodsMaxCharging(),
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
|
||||
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.asLogSummary
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
|
||||
@@ -15,13 +16,20 @@ object Bugs {
|
||||
message: String,
|
||||
exception: Throwable
|
||||
) {
|
||||
log(TAG, VERBOSE) { "Reporting $exception" }
|
||||
log(tag, ERROR) { "$message\n${exception.asLog()}" }
|
||||
runCatching { log(TAG, VERBOSE) { "Reporting ${exception.asLogSummary()}" } }
|
||||
runCatching { log(tag, ERROR) { "$message\n${exception.asLog()}" } }
|
||||
|
||||
reporter?.notify(exception) ?: run {
|
||||
log(TAG, WARN) { "Bug tracking not initialized yet." }
|
||||
val bugReporter = reporter
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,16 +61,19 @@ object Logging {
|
||||
message: String
|
||||
) {
|
||||
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
||||
snapshot
|
||||
.filter { it.isLoggable(priority) }
|
||||
.forEach {
|
||||
it.log(
|
||||
priority = priority,
|
||||
tag = tag,
|
||||
metaData = metaData,
|
||||
message = message
|
||||
)
|
||||
snapshot.forEach {
|
||||
val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
|
||||
if (isLoggable) {
|
||||
runCatching {
|
||||
it.log(
|
||||
priority = priority,
|
||||
tag = tag,
|
||||
metaData = metaData,
|
||||
message = message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
@@ -110,14 +113,28 @@ inline fun log(
|
||||
}
|
||||
}
|
||||
|
||||
fun Throwable.asLog(): String {
|
||||
fun Throwable.asLog(): String = runCatching {
|
||||
val stringWriter = StringWriter(256)
|
||||
val printWriter = PrintWriter(stringWriter, false)
|
||||
printStackTrace(printWriter)
|
||||
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
|
||||
internal fun Any.logTagViaCallSite(): String {
|
||||
val javaClass = this::class.java
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package eu.darken.capod.common.permissions
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.PermissionChecker
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.withinApiLevel
|
||||
@@ -18,7 +17,7 @@ enum class Permission(
|
||||
val permissionId: String,
|
||||
val isScanBlocking: Boolean = false,
|
||||
val isGranted: (Context) -> Boolean = {
|
||||
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
|
||||
PermissionChecker.checkSelfPermission(it, permissionId) == PermissionChecker.PERMISSION_GRANTED
|
||||
},
|
||||
) {
|
||||
BLUETOOTH(
|
||||
@@ -87,4 +86,4 @@ enum class Permission(
|
||||
fun Permission.isRequired(context: Context): Boolean = when {
|
||||
!withinApiLevel(minApiLevel, maxApiLevel) -> false
|
||||
else -> !isGranted(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.datastore.createValue
|
||||
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
@@ -44,8 +43,6 @@ class GeneralSettings @Inject constructor(
|
||||
val keepConnectedNotificationAfterDisconnect =
|
||||
dataStore.createValue("core.monitor.notification.connected.keepafterdisconnected", false)
|
||||
|
||||
val scannerMode = dataStore.createValue("core.scanner.mode", ScannerMode.BALANCED, json, onErrorFallbackToDefault = true)
|
||||
|
||||
val oldMinimumSignalQuality = dataStore.createValue("core.signal.minimum", 0.20f)
|
||||
|
||||
val oldMainDeviceAddress = dataStore.createValue<BluetoothAddress?>(
|
||||
@@ -79,6 +76,8 @@ class GeneralSettings @Inject constructor(
|
||||
|
||||
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
|
||||
|
||||
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
|
||||
|
||||
val themeMode = dataStore.createValue(
|
||||
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
|
||||
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
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.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationController
|
||||
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
|
||||
@Inject lateinit var popUpWindow: PopUpWindow
|
||||
@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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
installSplashScreen()
|
||||
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
|
||||
navCtrl.setup(backStack)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Cold-start intent (the activity's launching 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) {
|
||||
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
|
||||
|
||||
override fun onNewIntent(intent: 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?) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.darken.capod.main.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
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
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
|
||||
@@ -386,7 +386,7 @@ fun DeviceSettingsScreen(
|
||||
val showSoundSection =
|
||||
(features.hasPersonalizedVolume && personalizedVol != null) ||
|
||||
(features.hasToneVolume && toneVol != null) ||
|
||||
features.hasMicrophoneMode
|
||||
(features.hasMicrophoneMode && device.microphoneMode != null)
|
||||
if (showSoundSection) {
|
||||
item("sound_section") {
|
||||
SoundCard(
|
||||
@@ -404,7 +404,7 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
|
||||
// ── Controls ─────────────────────────────────
|
||||
val showControlsSection = features.hasStemConfig ||
|
||||
val showControlsSection = (features.hasStemConfig && device.stemConfig != null) ||
|
||||
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
|
||||
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||
|
||||
@@ -439,6 +439,11 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
fun navToPressControls() = launch {
|
||||
log(TAG, INFO) { "navToPressControls()" }
|
||||
val profileId = targetProfileId.value ?: return@launch
|
||||
val device = deviceMonitor.getDeviceForProfile(profileId)
|
||||
if (device?.isAapReady != true) {
|
||||
log(TAG, INFO) { "navToPressControls(): aborted, device not AAP-ready" }
|
||||
return@launch
|
||||
}
|
||||
navTo(Nav.Main.PressControls(profileId = profileId))
|
||||
}
|
||||
|
||||
|
||||
@@ -29,10 +29,10 @@ internal fun ControlsCard(
|
||||
val volSwipe = device.volumeSwipe
|
||||
val volSwipeLen = device.volumeSwipeLength
|
||||
|
||||
val showPressControlsNav = features.hasStemConfig ||
|
||||
features.hasPressSpeed ||
|
||||
features.hasPressHoldDuration ||
|
||||
features.hasEndCallMuteMic
|
||||
val showPressControlsNav = (features.hasStemConfig && device.stemConfig != null) ||
|
||||
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||
(features.hasEndCallMuteMic && device.endCallMuteMic != null)
|
||||
|
||||
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
|
||||
if (showPressControlsNav) {
|
||||
|
||||
@@ -133,7 +133,10 @@ internal fun NoiseControlCard(
|
||||
text = stringResource(R.string.press_controls_long_press_anc_cycle_info),
|
||||
type = InfoBoxType.INFO,
|
||||
action = {
|
||||
TextButton(onClick = onPressControlsClick) {
|
||||
TextButton(
|
||||
onClick = onPressControlsClick,
|
||||
enabled = enabled,
|
||||
) {
|
||||
Text(stringResource(R.string.device_settings_noise_control_open_press_controls_action))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -81,7 +81,7 @@ internal fun SoundCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (features.hasMicrophoneMode) {
|
||||
if (features.hasMicrophoneMode && device.microphoneMode != null) {
|
||||
if (isPro) {
|
||||
val micMode = device.microphoneMode
|
||||
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
|
||||
|
||||
@@ -59,6 +59,7 @@ import eu.darken.capod.main.ui.overview.cards.DualPodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
|
||||
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
||||
import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
||||
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
|
||||
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||
@@ -67,6 +68,22 @@ import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import java.time.Instant
|
||||
|
||||
internal fun profiledDeviceKey(
|
||||
device: PodDevice,
|
||||
index: Int,
|
||||
duplicateProfileIds: Set<String>,
|
||||
): String {
|
||||
val pid = requireNotNull(device.profileId)
|
||||
return if (pid !in duplicateProfileIds) {
|
||||
"profiled:$pid"
|
||||
} else {
|
||||
"profiled:$pid:${device.identifier ?: "idx:$index"}"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun unmatchedDeviceKey(device: PodDevice, index: Int): String =
|
||||
"unmatched:${device.identifier ?: "idx:$index"}"
|
||||
|
||||
@Composable
|
||||
fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
ErrorEventHandler(vm)
|
||||
@@ -158,7 +175,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
onUpgrade = { vm.onUpgrade() },
|
||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
|
||||
onDeviceSettings = { device ->
|
||||
if (currentState.showReactionsHint) vm.dismissReactionsHint()
|
||||
vm.goToDeviceSettings(device)
|
||||
},
|
||||
onEditProfile = { device -> vm.goToEditProfile(device) },
|
||||
onToggleDeviceExpansion = { device ->
|
||||
device.profileId?.let { vm.toggleDeviceExpansion(it) }
|
||||
@@ -286,9 +306,21 @@ fun OverviewScreen(
|
||||
|
||||
// 4. Profiled device cards (limited to 1 for free users)
|
||||
if (!state.isScanBlocked && state.isBluetoothEnabled) {
|
||||
if (state.showReactionsHint && state.visibleProfiledDevices.isNotEmpty()) {
|
||||
item(key = "reactions_hint") {
|
||||
ReactionsMovedHintCard()
|
||||
}
|
||||
}
|
||||
|
||||
val duplicateProfileIds = state.visibleProfiledDevices
|
||||
.mapNotNull { it.profileId }
|
||||
.groupingBy { it }
|
||||
.eachCount()
|
||||
.filterValues { it > 1 }
|
||||
.keys
|
||||
itemsIndexed(
|
||||
items = state.visibleProfiledDevices,
|
||||
key = { _, device -> requireNotNull(device.profileId) },
|
||||
key = { index, device -> profiledDeviceKey(device, index, duplicateProfileIds) },
|
||||
) { index, device ->
|
||||
val isCollapsed = !state.isExpanded(device, index)
|
||||
val isToggleable = state.isToggleable(device, index)
|
||||
@@ -337,10 +369,10 @@ fun OverviewScreen(
|
||||
}
|
||||
|
||||
if (state.showUnmatchedDevices) {
|
||||
items(
|
||||
itemsIndexed(
|
||||
items = state.unmatchedDevices,
|
||||
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" },
|
||||
) { device ->
|
||||
key = { index, device -> unmatchedDeviceKey(device, index) },
|
||||
) { _, device ->
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
isPro = state.upgradeInfo.isPro,
|
||||
@@ -414,6 +446,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
||||
),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||
showUnmatchedDevices = false,
|
||||
showReactionsHint = true,
|
||||
),
|
||||
onRequestPermission = {},
|
||||
onBluetoothSettings = {},
|
||||
|
||||
@@ -4,6 +4,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.value
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
@@ -22,6 +23,7 @@ import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
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.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
@@ -130,7 +132,9 @@ class OverviewViewModel @Inject constructor(
|
||||
upgradeRepo.upgradeInfo,
|
||||
showUnmatchedDevices,
|
||||
userExpansionOverrides,
|
||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds ->
|
||||
generalSettings.reactionsHintDismissed.flow,
|
||||
profilesRepo.hadLegacyReactionData,
|
||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
|
||||
// Prune stale overrides (profiles that no longer exist)
|
||||
val currentProfileIds = profiles.map { it.id }.toSet()
|
||||
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
|
||||
@@ -145,6 +149,7 @@ class OverviewViewModel @Inject constructor(
|
||||
upgradeInfo = upgradeInfo,
|
||||
showUnmatchedDevices = showUnmatched,
|
||||
userExpandedIds = prunedExpandedIds,
|
||||
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed,
|
||||
)
|
||||
}.asLiveState()
|
||||
|
||||
@@ -160,6 +165,7 @@ class OverviewViewModel @Inject constructor(
|
||||
val upgradeInfo: UpgradeRepo.Info,
|
||||
val showUnmatchedDevices: Boolean,
|
||||
val userExpandedIds: Set<String> = emptySet(),
|
||||
val showReactionsHint: Boolean = false,
|
||||
) {
|
||||
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||
|
||||
@@ -170,7 +176,7 @@ class OverviewViewModel @Inject constructor(
|
||||
|
||||
val profiledDevices: List<PodDevice> by lazy {
|
||||
devices.filter { it.profileId != null }.sortedWith(
|
||||
compareBy<PodDevice> { deviceTierRank(it) }
|
||||
compareBy<PodDevice> { it.tierRank() }
|
||||
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
||||
)
|
||||
}
|
||||
@@ -252,6 +258,13 @@ class OverviewViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissReactionsHint() {
|
||||
log(TAG, INFO) { "dismissReactionsHint()" }
|
||||
launch {
|
||||
generalSettings.reactionsHintDismissed.value(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPermission(permission: Permission) {
|
||||
log(TAG, INFO) { "requestPermission($permission)" }
|
||||
requestPermissionEvent.tryEmit(permission)
|
||||
@@ -272,12 +285,5 @@ class OverviewViewModel @Inject constructor(
|
||||
companion object {
|
||||
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 {
|
||||
device.isSystemConnected -> 0
|
||||
device.isLive -> 1
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -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.HasPodStyle
|
||||
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
|
||||
|
||||
@Composable
|
||||
@@ -168,7 +166,7 @@ fun DualPodsCard(
|
||||
}
|
||||
|
||||
if (device.profileId != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
@@ -227,7 +225,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
) {
|
||||
PodGauge(
|
||||
iconRes = device.leftPodIcon,
|
||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
||||
batteryPercent = device.batteryLeft,
|
||||
chargingState = device.leftPodChargingState
|
||||
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||
isInEar = device.isLeftInEar ?: false,
|
||||
@@ -239,7 +237,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
|
||||
PodGauge(
|
||||
iconRes = device.rightPodIcon,
|
||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
||||
batteryPercent = device.batteryRight,
|
||||
chargingState = device.rightPodChargingState
|
||||
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||
isInEar = device.isRightInEar ?: false,
|
||||
@@ -360,7 +358,7 @@ private fun PodGauge(
|
||||
|
||||
// Battery percentage
|
||||
Text(
|
||||
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
|
||||
text = formatBatteryPercent(context, batteryPercent),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (batteryPercent >= 0f) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
@@ -412,7 +410,7 @@ private fun CaseRow(
|
||||
)
|
||||
|
||||
BatteryCapsule(
|
||||
percent = device.batteryCase.toBatteryFloat(),
|
||||
percent = device.batteryCase,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(8.dp),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
|
||||
@Composable
|
||||
fun ReactionsMovedHintCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.overview_reactions_hint_title),
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.overview_reactions_hint_body),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 18.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun ReactionsMovedHintCardPreview() = PreviewWrapper {
|
||||
ReactionsMovedHintCard()
|
||||
}
|
||||
@@ -28,8 +28,8 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -60,7 +60,9 @@ import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import java.time.Instant
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -150,7 +152,7 @@ fun SinglePodsCard(
|
||||
}
|
||||
|
||||
if (device.profileId != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
@@ -192,17 +194,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
|
||||
val percent = device.batteryHeadset
|
||||
val isKnown = isKnownBattery(percent)
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = clamped ?: 0f,
|
||||
targetValue = batteryProgress(percent),
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "gaugeProgress",
|
||||
)
|
||||
|
||||
val ringColor = when {
|
||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
@@ -237,7 +240,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
)
|
||||
|
||||
// Progress ring
|
||||
if (clamped != null) {
|
||||
if (isKnown) {
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.size(88.dp),
|
||||
@@ -250,9 +253,9 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
|
||||
// Battery text inside ring
|
||||
Text(
|
||||
text = formatBatteryPercent(context, device.batteryHeadset),
|
||||
text = formatBatteryPercent(context, percent),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (device.batteryHeadset != null) {
|
||||
color = if (isKnown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
+17
-15
@@ -33,17 +33,19 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
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.isKnownBattery
|
||||
|
||||
@Composable
|
||||
fun CompactBatterySummary(
|
||||
device: PodDevice,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hasAnyBattery = device.batteryLeft != null
|
||||
|| device.batteryRight != null
|
||||
|| device.batteryHeadset != null
|
||||
|| device.batteryCase != null
|
||||
val hasAnyBattery = isKnownBattery(device.batteryLeft)
|
||||
|| isKnownBattery(device.batteryRight)
|
||||
|| isKnownBattery(device.batteryHeadset)
|
||||
|| isKnownBattery(device.batteryCase)
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
@@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) {
|
||||
percent = device.batteryRight,
|
||||
)
|
||||
|
||||
if (device.hasCase && device.batteryCase != null) {
|
||||
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
MiniCaseCluster(device = device)
|
||||
}
|
||||
@@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) {
|
||||
iconRes = null,
|
||||
percent = device.batteryHeadset,
|
||||
)
|
||||
if (device.hasCase && device.batteryCase != null) {
|
||||
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
MiniCaseCluster(device = device)
|
||||
}
|
||||
@@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() {
|
||||
@Composable
|
||||
private fun MiniPodRing(
|
||||
iconRes: Int?,
|
||||
percent: Float?,
|
||||
percent: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clamped = percent?.coerceIn(0f, 1f)
|
||||
val isKnown = isKnownBattery(percent)
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = clamped ?: 0f,
|
||||
targetValue = batteryProgress(percent),
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "miniGaugeProgress",
|
||||
)
|
||||
|
||||
val ringColor = when {
|
||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
@@ -155,7 +157,7 @@ private fun MiniPodRing(
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
if (clamped != null) {
|
||||
if (isKnown) {
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.size(28.dp),
|
||||
@@ -177,7 +179,7 @@ private fun MiniPodRing(
|
||||
Text(
|
||||
text = formatBatteryPercent(context, percent),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (percent != null) {
|
||||
color = if (isKnown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -204,7 +206,7 @@ private fun MiniCaseCluster(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
BatteryCapsule(
|
||||
percent = device.batteryCase ?: -1f,
|
||||
percent = device.batteryCase,
|
||||
modifier = Modifier
|
||||
.width(36.dp)
|
||||
.height(6.dp),
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -70,6 +71,18 @@ fun PressControlsScreenHost(
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
val currentState = state ?: return
|
||||
|
||||
val isAapConnected = currentState.device?.isAapConnected == true
|
||||
var hasSeenAapConnected by rememberSaveable(profileId) { mutableStateOf(false) }
|
||||
var didAutoNavigate by rememberSaveable(profileId) { mutableStateOf(false) }
|
||||
LaunchedEffect(profileId, isAapConnected) {
|
||||
if (isAapConnected) {
|
||||
hasSeenAapConnected = true
|
||||
} else if (hasSeenAapConnected && !didAutoNavigate) {
|
||||
didAutoNavigate = true
|
||||
vm.navUp()
|
||||
}
|
||||
}
|
||||
|
||||
PressControlsScreen(
|
||||
state = currentState,
|
||||
snackbarHostState = snackbarHostState,
|
||||
|
||||
@@ -19,7 +19,6 @@ import androidx.compose.material.icons.twotone.FilterList
|
||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||
import androidx.compose.material.icons.twotone.Notifications
|
||||
import androidx.compose.material.icons.twotone.Palette
|
||||
import androidx.compose.material.icons.twotone.SettingsBluetooth
|
||||
import androidx.compose.material.icons.automirrored.twotone.ViewList
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -45,7 +44,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
@@ -70,7 +68,6 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
||||
state = it,
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
|
||||
onScannerModeSelected = { mode -> vm.setScannerMode(mode) },
|
||||
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
|
||||
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
|
||||
onDebugSettings = { vm.goToDebugSettings() },
|
||||
@@ -90,7 +87,6 @@ fun GeneralSettingsScreen(
|
||||
state: GeneralSettingsViewModel.State,
|
||||
onNavigateUp: () -> Unit,
|
||||
onMonitorModeSelected: (MonitorMode) -> Unit,
|
||||
onScannerModeSelected: (ScannerMode) -> Unit,
|
||||
onShowConnectedNotificationChanged: (Boolean) -> Unit,
|
||||
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
|
||||
onDebugSettings: () -> Unit,
|
||||
@@ -104,7 +100,6 @@ fun GeneralSettingsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showMonitorModeDialog by remember { mutableStateOf(false) }
|
||||
var showScannerModeDialog by remember { mutableStateOf(false) }
|
||||
var showColorDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
|
||||
@@ -138,14 +133,6 @@ fun GeneralSettingsScreen(
|
||||
onClick = { showMonitorModeDialog = true },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_scanner_mode_label),
|
||||
subtitle = stringResource(state.scannerMode.labelRes),
|
||||
icon = Icons.TwoTone.SettingsBluetooth,
|
||||
onClick = { showScannerModeDialog = true },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
|
||||
}
|
||||
@@ -313,20 +300,6 @@ fun GeneralSettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showScannerModeDialog) {
|
||||
ListPreferenceDialog(
|
||||
title = stringResource(R.string.settings_scanner_mode_label),
|
||||
entries = ScannerMode.entries,
|
||||
selectedEntry = state.scannerMode,
|
||||
onEntrySelected = {
|
||||
onScannerModeSelected(it)
|
||||
showScannerModeDialog = false
|
||||
},
|
||||
entryLabel = { stringResource(it.labelRes) },
|
||||
onDismiss = { showScannerModeDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showColorDialog) {
|
||||
ThemeColorSelectorDialog(
|
||||
selectedColor = state.themeState.color,
|
||||
@@ -342,7 +315,6 @@ fun GeneralSettingsScreen(
|
||||
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
|
||||
isPro = isPro,
|
||||
monitorMode = MonitorMode.AUTOMATIC,
|
||||
scannerMode = ScannerMode.BALANCED,
|
||||
showConnectedNotification = true,
|
||||
keepNotificationAfterDisconnect = false,
|
||||
isOffloadedFilteringDisabled = false,
|
||||
@@ -358,7 +330,6 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
|
||||
state = previewGeneralState(isPro = true),
|
||||
onNavigateUp = {},
|
||||
onMonitorModeSelected = {},
|
||||
onScannerModeSelected = {},
|
||||
onShowConnectedNotificationChanged = {},
|
||||
onKeepNotificationAfterDisconnectChanged = {},
|
||||
onDebugSettings = {},
|
||||
@@ -375,7 +346,6 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
|
||||
state = previewGeneralState(isPro = false),
|
||||
onNavigateUp = {},
|
||||
onMonitorModeSelected = {},
|
||||
onScannerModeSelected = {},
|
||||
onShowConnectedNotificationChanged = {},
|
||||
onKeepNotificationAfterDisconnectChanged = {},
|
||||
onDebugSettings = {},
|
||||
|
||||
+4
-13
@@ -1,7 +1,6 @@
|
||||
package eu.darken.capod.main.ui.settings.general
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -32,7 +31,6 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
data class State(
|
||||
val isPro: Boolean,
|
||||
val monitorMode: MonitorMode,
|
||||
val scannerMode: ScannerMode,
|
||||
val showConnectedNotification: Boolean,
|
||||
val keepNotificationAfterDisconnect: Boolean,
|
||||
val isOffloadedFilteringDisabled: Boolean,
|
||||
@@ -46,12 +44,11 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
val state = combine(
|
||||
combine(
|
||||
generalSettings.monitorMode.flow,
|
||||
generalSettings.scannerMode.flow,
|
||||
generalSettings.useExtraMonitorNotification.flow,
|
||||
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
||||
) { monitorMode, scannerMode, showNotif, keepNotif ->
|
||||
) { monitorMode, showNotif, keepNotif ->
|
||||
@Suppress("USELESS_CAST")
|
||||
arrayOf<Any>(monitorMode as Any, scannerMode as Any, showNotif as Any, keepNotif as Any)
|
||||
arrayOf<Any>(monitorMode as Any, showNotif as Any, keepNotif as Any)
|
||||
},
|
||||
combine(
|
||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||
@@ -67,9 +64,8 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
State(
|
||||
isPro = isPro,
|
||||
monitorMode = general[0] as MonitorMode,
|
||||
scannerMode = general[1] as ScannerMode,
|
||||
showConnectedNotification = general[2] as Boolean,
|
||||
keepNotificationAfterDisconnect = general[3] as Boolean,
|
||||
showConnectedNotification = general[1] as Boolean,
|
||||
keepNotificationAfterDisconnect = general[2] as Boolean,
|
||||
isOffloadedFilteringDisabled = compat[0] as Boolean,
|
||||
isOffloadedBatchingDisabled = compat[1] as Boolean,
|
||||
useIndirectScanResultCallback = compat[2] as Boolean,
|
||||
@@ -82,11 +78,6 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
generalSettings.monitorMode.valueBlocking = mode
|
||||
}
|
||||
|
||||
fun setScannerMode(mode: ScannerMode) {
|
||||
log(TAG, INFO) { "setScannerMode($mode)" }
|
||||
generalSettings.scannerMode.valueBlocking = mode
|
||||
}
|
||||
|
||||
fun setShowConnectedNotification(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
|
||||
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import androidx.annotation.Keep
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
@@ -23,8 +24,9 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class AncGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
@@ -43,21 +45,15 @@ class AncGlanceWidget : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val ep: AncWidgetEntryPoint
|
||||
val appWidgetId: Int
|
||||
val initialIsPro: Boolean
|
||||
val initialProfileId: String?
|
||||
val cachedDevice: PodDevice?
|
||||
|
||||
try {
|
||||
ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java)
|
||||
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
||||
initialIsPro = ep.upgradeRepo().isPro()
|
||||
ep.widgetSettings().migrateLegacyConfigIfNeeded(
|
||||
appWidgetId,
|
||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
|
||||
)
|
||||
initialProfileId = ep.widgetSettings().getWidgetConfig(appWidgetId).profileId
|
||||
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
||||
provideContent {
|
||||
@@ -77,25 +73,40 @@ class AncGlanceWidget : GlanceAppWidget() {
|
||||
}
|
||||
|
||||
provideContent {
|
||||
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
|
||||
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
|
||||
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
||||
val widthDp = LocalSize.current.width
|
||||
val heightDp = LocalSize.current.height
|
||||
|
||||
val state = try {
|
||||
val config = ep.widgetSettings().getWidgetConfig(appWidgetId)
|
||||
val profileId = config.profileId
|
||||
val theme = config.theme
|
||||
// Glance keeps the content session alive and does not restart provideGlance()
|
||||
// for every update(). Observe a widget-key-deduped device flow so visible state
|
||||
// changes update active sessions without recomposing on every BLE advertisement.
|
||||
val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
|
||||
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
|
||||
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
||||
|
||||
val liveDevice = devices.firstOrNull { it.profileId == profileId }
|
||||
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
|
||||
|
||||
val profileLabel = profileId?.let { pid ->
|
||||
profiles.firstOrNull { it.id == pid }?.label
|
||||
val state = if (config != null) {
|
||||
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
|
||||
.onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
|
||||
.getOrDefault(false)
|
||||
val initialDevice = remember(config.profileId) {
|
||||
config.profileId?.let { pid ->
|
||||
runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } }
|
||||
.onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
val device by config.profileId
|
||||
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
|
||||
?.collectAsState(initial = initialDevice)
|
||||
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
|
||||
val profileLabel = config.profileId?.let { pid ->
|
||||
runCatching {
|
||||
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
|
||||
}
|
||||
.onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
|
||||
|
||||
val widthCells = getCellsForSize(widthDp.value.toInt())
|
||||
val heightCells = getCellsForSize(heightDp.value.toInt())
|
||||
@@ -111,14 +122,13 @@ class AncGlanceWidget : GlanceAppWidget() {
|
||||
AncWidgetRenderStateMapper.map(
|
||||
context = context,
|
||||
device = device,
|
||||
theme = theme,
|
||||
theme = config.theme,
|
||||
isPro = isPro,
|
||||
hasConfiguredProfile = profileId != null,
|
||||
hasConfiguredProfile = config.profileId != null,
|
||||
profileLabel = profileLabel,
|
||||
layout = layout,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
|
||||
} else {
|
||||
AncWidgetRenderState.Message(
|
||||
theme = WidgetTheme.DEFAULT,
|
||||
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.annotation.Keep
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
@@ -30,6 +29,7 @@ class AncModeActionCallback : ActionCallback {
|
||||
fun aapConnectionManager(): AapConnectionManager
|
||||
fun widgetSettings(): WidgetSettings
|
||||
fun deviceMonitor(): DeviceMonitor
|
||||
fun widgetManager(): WidgetManager
|
||||
}
|
||||
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
@@ -62,14 +62,14 @@ class AncModeActionCallback : ActionCallback {
|
||||
val device = ep.deviceMonitor().getDeviceForProfile(profileId)
|
||||
if (device == null) {
|
||||
log(TAG, ERROR) { "onAction: no device for profileId=$profileId" }
|
||||
AncGlanceWidget().updateAll(context)
|
||||
ep.widgetManager().refreshWidgets()
|
||||
return
|
||||
}
|
||||
|
||||
val address = device.address
|
||||
if (address == null) {
|
||||
log(TAG, ERROR) { "onAction: device has no address" }
|
||||
AncGlanceWidget().updateAll(context)
|
||||
ep.widgetManager().refreshWidgets()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class AncModeActionCallback : ActionCallback {
|
||||
log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" }
|
||||
}
|
||||
|
||||
AncGlanceWidget().updateAll(context)
|
||||
ep.widgetManager().refreshWidgets()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
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.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
@@ -57,13 +58,21 @@ object AncWidgetRenderStateMapper {
|
||||
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
|
||||
)
|
||||
|
||||
!device.isAapConnected -> AncWidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
primaryText = context.getString(R.string.anc_widget_aap_not_connected_label),
|
||||
)
|
||||
!device.isAapConnected -> {
|
||||
val secondaryRes = if (device.ble != null) {
|
||||
R.string.anc_widget_aap_not_connected_nearby_description
|
||||
} else {
|
||||
R.string.anc_widget_aap_not_connected_not_nearby_description
|
||||
}
|
||||
AncWidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
primaryText = profileLabel ?: device.getLabel(context),
|
||||
secondaryText = context.getString(secondaryRes),
|
||||
)
|
||||
}
|
||||
|
||||
!device.isAapReady -> AncWidgetRenderState.Message(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import androidx.annotation.Keep
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
@@ -23,8 +24,9 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
@@ -43,21 +45,15 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val ep: WidgetEntryPoint
|
||||
val appWidgetId: Int
|
||||
val initialIsPro: Boolean
|
||||
val initialProfileId: String?
|
||||
val cachedDevice: PodDevice?
|
||||
|
||||
try {
|
||||
ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
|
||||
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
||||
initialIsPro = ep.upgradeRepo().isPro()
|
||||
ep.widgetSettings().migrateLegacyConfigIfNeeded(
|
||||
appWidgetId,
|
||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
|
||||
)
|
||||
initialProfileId = ep.widgetSettings().getWidgetConfig(appWidgetId).profileId
|
||||
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
||||
provideContent {
|
||||
@@ -78,38 +74,51 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
}
|
||||
|
||||
provideContent {
|
||||
// Composable reads — must be outside try-catch
|
||||
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
|
||||
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
|
||||
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
||||
val widthDp = LocalSize.current.width
|
||||
val layout = BatteryLayout.forCells(getCellsForSize(widthDp.value.toInt()))
|
||||
|
||||
val state = try {
|
||||
val config = ep.widgetSettings().getWidgetConfig(appWidgetId)
|
||||
val profileId = config.profileId
|
||||
val theme = config.theme
|
||||
// Glance keeps the content session alive and does not restart provideGlance()
|
||||
// for every update(). Observe a widget-key-deduped device flow so visible state
|
||||
// changes update active sessions without recomposing on every BLE advertisement.
|
||||
val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
|
||||
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
|
||||
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
||||
|
||||
val liveDevice = devices.firstOrNull { it.profileId == profileId }
|
||||
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
|
||||
|
||||
val profileLabel = profileId?.let { pid ->
|
||||
profiles.firstOrNull { it.id == pid }?.label
|
||||
val state = if (config != null) {
|
||||
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
|
||||
.onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
|
||||
.getOrDefault(false)
|
||||
val initialDevice = remember(config.profileId) {
|
||||
config.profileId?.let { pid ->
|
||||
runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } }
|
||||
.onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
val device by config.profileId
|
||||
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
|
||||
?.collectAsState(initial = initialDevice)
|
||||
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
|
||||
val profileLabel = config.profileId?.let { pid ->
|
||||
runCatching {
|
||||
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
|
||||
}
|
||||
.onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
|
||||
|
||||
WidgetRenderStateMapper.map(
|
||||
context = context,
|
||||
device = device,
|
||||
theme = theme,
|
||||
theme = config.theme,
|
||||
isPro = isPro,
|
||||
hasConfiguredProfile = profileId != null,
|
||||
hasConfiguredProfile = config.profileId != null,
|
||||
profileLabel = profileLabel,
|
||||
layout = layout,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
|
||||
} else {
|
||||
WidgetRenderState.Message(
|
||||
theme = WidgetTheme.DEFAULT,
|
||||
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||
|
||||
@@ -31,7 +31,7 @@ import androidx.compose.ui.unit.sp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
@@ -160,7 +160,7 @@ private fun SinglePodPreview(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(state.percent.toBatteryOrNull()),
|
||||
text = formatPercent(state.percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
@@ -311,7 +311,7 @@ private fun PodItemRow(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent.toBatteryOrNull()),
|
||||
text = formatPercent(percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
@@ -354,7 +354,7 @@ private fun TinyPodItem(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent.toBatteryOrNull()),
|
||||
text = formatPercent(percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
@@ -382,9 +382,8 @@ private fun DeviceLabel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatPercent(percent: Float?): String {
|
||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||
}
|
||||
private fun formatPercent(percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
|
||||
@@ -27,7 +27,7 @@ import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import eu.darken.capod.R
|
||||
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
|
||||
|
||||
@Composable
|
||||
@@ -122,7 +122,7 @@ private fun GlanceSinglePod(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(state.percent.toBatteryOrNull()),
|
||||
text = formatGlancePercent(state.percent),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
@@ -270,7 +270,7 @@ private fun GlancePodItem(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
||||
text = formatGlancePercent(percent),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
@@ -312,7 +312,7 @@ private fun GlanceTinyPodItem(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
||||
text = formatGlancePercent(percent),
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.padding(start = 4.dp),
|
||||
@@ -341,6 +341,5 @@ private fun GlanceDeviceLabel(
|
||||
|
||||
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
||||
|
||||
private fun formatGlancePercent(percent: Float?): String {
|
||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||
}
|
||||
private fun formatGlancePercent(percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal fun DeviceMonitor.widgetDeviceFlow(profileId: String): Flow<PodDevice?> = devices
|
||||
.map { devices -> devices.firstOrNull { it.profileId == profileId } }
|
||||
.distinctUntilChangedBy { it?.toWidgetKey() }
|
||||
@@ -1,31 +1,41 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
/**
|
||||
* Snapshot of the [PodDevice] fields that affect widget rendering. Used to
|
||||
* gate widget refreshes so RSSI / reliability / scan-counter churn doesn't
|
||||
* cause a refresh on every BLE advertisement.
|
||||
* gate widget refreshes so RSSI / reliability / scan-counter / timestamp churn
|
||||
* doesn't cause a refresh on every BLE advertisement.
|
||||
*/
|
||||
internal data class WidgetDeviceKey(
|
||||
val profileId: String?,
|
||||
val profileLabel: String?,
|
||||
val model: PodModel,
|
||||
val batteryLeft: Float?,
|
||||
val batteryRight: Float?,
|
||||
val batteryCase: Float?,
|
||||
val batteryHeadset: Float?,
|
||||
val batteryLeft: Float,
|
||||
val batteryRight: Float,
|
||||
val batteryCase: Float,
|
||||
val batteryHeadset: Float,
|
||||
val isLeftPodCharging: Boolean?,
|
||||
val isRightPodCharging: Boolean?,
|
||||
val isCaseCharging: Boolean?,
|
||||
val isHeadsetBeingCharged: Boolean?,
|
||||
val isLeftInEar: Boolean?,
|
||||
val isRightInEar: Boolean?,
|
||||
val isBeingWorn: Boolean?,
|
||||
val isAapConnected: Boolean,
|
||||
val isAapReady: Boolean,
|
||||
val hasBleAdvertisement: Boolean,
|
||||
val ancMode: AapSetting.AncMode.Value?,
|
||||
val pendingAncMode: AapSetting.AncMode.Value?,
|
||||
val visibleAncModes: List<AapSetting.AncMode.Value>,
|
||||
)
|
||||
|
||||
internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
||||
profileId = profileId,
|
||||
profileLabel = label,
|
||||
model = model,
|
||||
batteryLeft = batteryLeft,
|
||||
batteryRight = batteryRight,
|
||||
@@ -35,6 +45,13 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
||||
isRightPodCharging = isRightPodCharging,
|
||||
isCaseCharging = isCaseCharging,
|
||||
isHeadsetBeingCharged = isHeadsetBeingCharged,
|
||||
isLeftInEar = isLeftInEar,
|
||||
isRightInEar = isRightInEar,
|
||||
isBeingWorn = isBeingWorn,
|
||||
isAapConnected = isAapConnected,
|
||||
isAapReady = isAapReady,
|
||||
hasBleAdvertisement = ble != null,
|
||||
ancMode = ancMode?.current,
|
||||
pendingAncMode = pendingAncMode,
|
||||
visibleAncModes = visibleAncModes,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -13,7 +23,66 @@ class WidgetManager @Inject constructor(
|
||||
) {
|
||||
|
||||
suspend fun refreshWidgets() {
|
||||
BatteryGlanceWidget().updateAll(context)
|
||||
AncGlanceWidget().updateAll(context)
|
||||
val manager = GlanceAppWidgetManager(context)
|
||||
refreshWidget(
|
||||
name = "battery",
|
||||
widget = BatteryGlanceWidget(),
|
||||
providerClass = BatteryGlanceWidget::class.java,
|
||||
receiverClass = WidgetProvider::class.java,
|
||||
manager = manager,
|
||||
)
|
||||
refreshWidget(
|
||||
name = "anc",
|
||||
widget = AncGlanceWidget(),
|
||||
providerClass = AncGlanceWidget::class.java,
|
||||
receiverClass = AncWidgetProvider::class.java,
|
||||
manager = manager,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T : GlanceAppWidget> refreshWidget(
|
||||
name: String,
|
||||
widget: T,
|
||||
providerClass: Class<T>,
|
||||
receiverClass: Class<*>,
|
||||
manager: GlanceAppWidgetManager,
|
||||
) {
|
||||
val ids = manager.getGlanceIds(providerClass).ifEmpty {
|
||||
val appWidgetIds = AppWidgetManager.getInstance(context)
|
||||
.getAppWidgetIds(ComponentName(context, receiverClass))
|
||||
.toList()
|
||||
if (appWidgetIds.isNotEmpty()) {
|
||||
log(TAG, WARN) { "refresh($name): Glance IDs empty, falling back to platform ids=$appWidgetIds" }
|
||||
}
|
||||
appWidgetIds.mapNotNull { appWidgetId ->
|
||||
runCatching { manager.getGlanceIdBy(appWidgetId) }
|
||||
.onFailure { error ->
|
||||
log(TAG, ERROR) { "refresh($name): failed to resolve widgetId=$appWidgetId: ${error.asLog()}" }
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
log(TAG, VERBOSE) { "refresh($name): ids=${ids.toAppWidgetIds(manager)}" }
|
||||
|
||||
ids.forEach { id ->
|
||||
runCatching { widget.update(context, id) }
|
||||
.onFailure { error ->
|
||||
log(TAG, ERROR) { "refresh($name): update failed for id=${id.toAppWidgetId(manager)}: ${error.asLog()}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<GlanceId>.toAppWidgetIds(manager: GlanceAppWidgetManager): List<Int> =
|
||||
map { it.toAppWidgetId(manager) }
|
||||
|
||||
private fun GlanceId.toAppWidgetId(manager: GlanceAppWidgetManager): Int = runCatching {
|
||||
manager.getAppWidgetId(this)
|
||||
}.getOrElse {
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = logTag("Widget", "Manager")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
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.toBatteryFloat
|
||||
|
||||
object WidgetRenderStateMapper {
|
||||
|
||||
@@ -43,15 +42,15 @@ object WidgetRenderStateMapper {
|
||||
layout = layout,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
leftIcon = device.leftPodIcon,
|
||||
leftPercent = device.batteryLeft.toBatteryFloat(),
|
||||
leftPercent = device.batteryLeft,
|
||||
leftCharging = device.isLeftPodCharging == true,
|
||||
leftInEar = device.isLeftInEar == true,
|
||||
rightIcon = device.rightPodIcon,
|
||||
rightPercent = device.batteryRight.toBatteryFloat(),
|
||||
rightPercent = device.batteryRight,
|
||||
rightCharging = device.isRightPodCharging == true,
|
||||
rightInEar = device.isRightInEar == true,
|
||||
caseIcon = device.caseIcon,
|
||||
casePercent = device.batteryCase.toBatteryFloat(),
|
||||
casePercent = device.batteryCase,
|
||||
caseCharging = device.isCaseCharging == true,
|
||||
)
|
||||
|
||||
@@ -63,7 +62,7 @@ object WidgetRenderStateMapper {
|
||||
layout = layout,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
headsetIcon = device.iconRes,
|
||||
percent = device.batteryHeadset.toBatteryFloat(),
|
||||
percent = device.batteryHeadset,
|
||||
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||
charging = device.isHeadsetBeingCharged == 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.TimeSource
|
||||
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.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
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.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -86,7 +90,34 @@ class DeviceMonitor @Inject constructor(
|
||||
connectedAddresses = connectedAddresses,
|
||||
)
|
||||
}.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(
|
||||
@@ -181,14 +212,25 @@ class DeviceMonitor @Inject constructor(
|
||||
dedupedLiveDevices + nonLiveDevices
|
||||
}.replayingShare(appScope)
|
||||
|
||||
private val reportedPersistFailures = mutableSetOf<String>()
|
||||
|
||||
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
||||
for (device in devices) {
|
||||
val profileId = device.profileId ?: continue
|
||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
||||
try {
|
||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
||||
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||
deviceStateCache.save(profileId, newState)
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.protocol.AapDeviceInfo
|
||||
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.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
@@ -138,18 +139,24 @@ data class PodDevice(
|
||||
}
|
||||
}
|
||||
|
||||
// Battery — AAP preferred, BLE fallback, then cached
|
||||
val batteryLeft: Float?
|
||||
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
|
||||
// Battery — AAP preferred, BLE fallback, then cached. Returns BATTERY_UNKNOWN (-1f)
|
||||
// for unknown to keep the type primitive; this is the boundary that previously emitted
|
||||
// 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?
|
||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
|
||||
val batteryRight: Float
|
||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
?: cached?.right?.percent ?: BATTERY_UNKNOWN
|
||||
|
||||
val batteryCase: Float?
|
||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
|
||||
val batteryCase: Float
|
||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||
?: cached?.case?.percent ?: BATTERY_UNKNOWN
|
||||
|
||||
val batteryHeadset: Float?
|
||||
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
|
||||
val batteryHeadset: Float
|
||||
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). */
|
||||
val isBatteryCached: Boolean
|
||||
@@ -205,18 +212,27 @@ data class PodDevice(
|
||||
}
|
||||
} ?: (ble as? DualApplePods)?.primaryPod
|
||||
|
||||
private fun AapSetting.EarDetection.PodPlacement.isInEar(): Boolean =
|
||||
this == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
|
||||
private fun AapSetting.EarDetection.samePlacementInEarOrNull(): Boolean? =
|
||||
if (primaryPod == secondaryPod) primaryPod.isInEar() else null
|
||||
|
||||
// Ear detection — AAP preferred (lower latency), BLE fallback.
|
||||
// AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary.
|
||||
val isLeftInEar: Boolean?
|
||||
get() {
|
||||
val earDetection = aap?.aapEarDetection
|
||||
val primary = resolvedPrimaryPod
|
||||
if (earDetection != null && primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
|
||||
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
} else {
|
||||
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
if (earDetection != null) {
|
||||
if (primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
|
||||
earDetection.primaryPod.isInEar()
|
||||
} else {
|
||||
earDetection.secondaryPod.isInEar()
|
||||
}
|
||||
}
|
||||
earDetection.samePlacementInEarOrNull()?.let { return it }
|
||||
}
|
||||
return (ble as? HasEarDetectionDual)?.isLeftPodInEar
|
||||
}
|
||||
@@ -225,12 +241,15 @@ data class PodDevice(
|
||||
get() {
|
||||
val earDetection = aap?.aapEarDetection
|
||||
val primary = resolvedPrimaryPod
|
||||
if (earDetection != null && primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
|
||||
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
} else {
|
||||
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
if (earDetection != null) {
|
||||
if (primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
|
||||
earDetection.primaryPod.isInEar()
|
||||
} else {
|
||||
earDetection.secondaryPod.isInEar()
|
||||
}
|
||||
}
|
||||
earDetection.samePlacementInEarOrNull()?.let { return it }
|
||||
}
|
||||
return (ble as? HasEarDetectionDual)?.isRightPodInEar
|
||||
}
|
||||
|
||||
@@ -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.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -77,9 +76,7 @@ class AapAutoConnect @Inject constructor(
|
||||
|
||||
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
||||
try {
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||
log(TAG) { "AAP connected to $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
||||
@@ -102,9 +99,7 @@ class AapAutoConnect @Inject constructor(
|
||||
|
||||
try {
|
||||
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}" }
|
||||
break
|
||||
} catch (retryException: Exception) {
|
||||
@@ -164,9 +159,7 @@ class AapAutoConnect @Inject constructor(
|
||||
|
||||
try {
|
||||
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" }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
@@ -218,7 +211,7 @@ class AapAutoConnect @Inject constructor(
|
||||
val bonded = bluetoothManager.bondedDevices().first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (bonded != null) {
|
||||
aapManager.connect(address, bonded.internal, detectedModel)
|
||||
aapManager.connect(address, bonded.internal!!, detectedModel)
|
||||
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -231,6 +224,5 @@ class AapAutoConnect @Inject constructor(
|
||||
companion object {
|
||||
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)
|
||||
private val CONNECT_TIMEOUT = 5.seconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
@@ -45,12 +47,13 @@ import javax.inject.Singleton
|
||||
class BlePodMonitor @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val bleScanner: BleScanner,
|
||||
private val bleScanModeController: BleScanModeController,
|
||||
private val podFactory: PodFactory,
|
||||
private val timeSource: TimeSource,
|
||||
private val generalSettings: GeneralSettings,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
private val debugSettings: DebugSettings,
|
||||
permissionTool: PermissionTool,
|
||||
private val permissionTool: PermissionTool,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
|
||||
@@ -88,8 +91,10 @@ class BlePodMonitor @Inject constructor(
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.WARN
|
||||
) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
|
||||
false
|
||||
) { "PodMonitor failed due to missing permission, rechecking and retrying: ${cause.asLog()}" }
|
||||
permissionTool.recheck()
|
||||
delay(3000)
|
||||
true
|
||||
} else {
|
||||
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
||||
delay(3000)
|
||||
@@ -128,7 +133,7 @@ class BlePodMonitor @Inject constructor(
|
||||
)
|
||||
|
||||
private fun createBleScanner() = combine(
|
||||
generalSettings.scannerMode.flow,
|
||||
bleScanModeController.scannerMode,
|
||||
debugSettings.showUnfiltered.flow,
|
||||
generalSettings.isOffloadedBatchingDisabled.flow,
|
||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||
@@ -159,13 +164,27 @@ class BlePodMonitor @Inject constructor(
|
||||
else -> ProximityPairing.getBleScanFilter()
|
||||
}
|
||||
|
||||
bleScanner.scan(
|
||||
filters = filters,
|
||||
scannerMode = options.scannerMode,
|
||||
disableOffloadFiltering = options.offloadedFilteringDisabled,
|
||||
disableOffloadBatching = options.offloadedBatchingDisabled,
|
||||
disableDirectScanCallback = options.disableDirectCallback,
|
||||
)
|
||||
flow {
|
||||
emitAll(
|
||||
bleScanner.scan(
|
||||
filters = filters,
|
||||
scannerMode = options.scannerMode,
|
||||
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() }
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package eu.darken.capod.monitor.core.ble
|
||||
|
||||
import eu.darken.capod.common.AppForegroundState
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.replayingShare
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class BleScanModeController @Inject constructor(
|
||||
@AppScope appScope: CoroutineScope,
|
||||
appForegroundState: AppForegroundState,
|
||||
profilesRepo: DeviceProfilesRepo,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
) {
|
||||
|
||||
private val overrideCounts = MutableStateFlow<Map<ScannerMode, Int>>(emptyMap())
|
||||
private val activeOverride: Flow<ScannerMode?> = overrideCounts.map { it.topPriority() }
|
||||
|
||||
val scannerMode: Flow<ScannerMode> = combine(
|
||||
activeOverride,
|
||||
appForegroundState.isForeground,
|
||||
profilesRepo.profiles,
|
||||
bluetoothManager.connectedDevices
|
||||
.onStart { emit(emptyList()) }
|
||||
.catch {
|
||||
log(TAG, Logging.Priority.WARN) { "connectedDevices failed, treating as empty: $it" }
|
||||
emit(emptyList())
|
||||
},
|
||||
bluetoothManager.bondedDeviceAddresses,
|
||||
) { override, isForeground, profiles, connectedDevices, bondedAddresses ->
|
||||
resolveScannerMode(
|
||||
overrideMode = override,
|
||||
isForeground = isForeground,
|
||||
profileAddresses = profiles.mapNotNull { it.address }.toSet(),
|
||||
bondedAddresses = bondedAddresses,
|
||||
connectedAddresses = connectedDevices.map { it.address }.toSet(),
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { log(TAG) { "Effective scanner mode: $it" } }
|
||||
.setupCommonEventHandlers(TAG) { "scannerMode" }
|
||||
.replayingShare(appScope)
|
||||
|
||||
suspend fun <T> withTemporaryOverride(mode: ScannerMode, block: suspend () -> T): T {
|
||||
log(TAG) { "withTemporaryOverride($mode) acquire" }
|
||||
overrideCounts.update { it.adjust(mode, +1) }
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
overrideCounts.update { it.adjust(mode, -1) }
|
||||
log(TAG) { "withTemporaryOverride($mode) release" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<ScannerMode, Int>.adjust(mode: ScannerMode, delta: Int): Map<ScannerMode, Int> {
|
||||
val current = this[mode] ?: 0
|
||||
val next = current + delta
|
||||
return when {
|
||||
next <= 0 -> this - mode
|
||||
else -> this + (mode to next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<ScannerMode, Int>.topPriority(): ScannerMode? = OVERRIDE_PRIORITY.firstOrNull { containsKey(it) }
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Bluetooth", "ScannerMode")
|
||||
private val OVERRIDE_PRIORITY = listOf(ScannerMode.LOW_LATENCY, ScannerMode.BALANCED, ScannerMode.LOW_POWER)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resolveScannerMode(
|
||||
overrideMode: ScannerMode?,
|
||||
isForeground: Boolean,
|
||||
profileAddresses: Set<BluetoothAddress>,
|
||||
bondedAddresses: Set<BluetoothAddress>,
|
||||
connectedAddresses: Set<BluetoothAddress>,
|
||||
): ScannerMode {
|
||||
if (overrideMode != null) return overrideMode
|
||||
|
||||
val connectedProfileAddresses = profileAddresses.normalized()
|
||||
.intersect(bondedAddresses.normalized())
|
||||
.intersect(connectedAddresses.normalized())
|
||||
|
||||
return when {
|
||||
connectedProfileAddresses.isNotEmpty() -> ScannerMode.LOW_LATENCY
|
||||
isForeground -> ScannerMode.BALANCED
|
||||
else -> ScannerMode.LOW_POWER
|
||||
}
|
||||
}
|
||||
|
||||
private fun Iterable<BluetoothAddress>.normalized(): Set<BluetoothAddress> = this
|
||||
.map { it.uppercase(Locale.US) }
|
||||
.toSet()
|
||||
@@ -36,7 +36,9 @@ data class CachedDeviceState(
|
||||
) {
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
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(
|
||||
name = deviceName ?: "",
|
||||
modelNumber = "",
|
||||
|
||||
+30
-28
@@ -3,9 +3,11 @@ package eu.darken.capod.monitor.core.cache
|
||||
import eu.darken.capod.common.SystemTimeSource
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
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.SingleBlePodSnapshot
|
||||
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.Instant
|
||||
|
||||
@@ -14,7 +16,7 @@ import java.time.Instant
|
||||
* Returns null if:
|
||||
* - The device is not live (cached-only)
|
||||
* - 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)
|
||||
*/
|
||||
fun PodDevice.toCachedState(
|
||||
@@ -24,15 +26,18 @@ fun PodDevice.toCachedState(
|
||||
if (!isLive) return null
|
||||
val pid = profileId ?: return null
|
||||
|
||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
|
||||
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null
|
||||
|
||||
// RAW LIVE EXTRACTION — must NOT use device.batteryLeft etc. (which fall back to cache).
|
||||
// Reading the unified getter would re-stamp stale cached readings as fresh live data.
|
||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: BATTERY_UNKNOWN
|
||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: BATTERY_UNKNOWN
|
||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: BATTERY_UNKNOWN
|
||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: BATTERY_UNKNOWN
|
||||
val liveDeviceInfo = aap?.deviceInfo
|
||||
|
||||
if (!isKnownBattery(liveLeft) && !isKnownBattery(liveRight) &&
|
||||
!isKnownBattery(liveCase) && !isKnownBattery(liveHeadset) && liveDeviceInfo == null
|
||||
) return null
|
||||
|
||||
val newState = CachedDeviceState(
|
||||
profileId = pid,
|
||||
model = model,
|
||||
@@ -60,40 +65,37 @@ fun PodDevice.toCachedState(
|
||||
}
|
||||
|
||||
private fun mergeBatterySlot(
|
||||
livePercent: Float?,
|
||||
livePercent: Float,
|
||||
existing: CachedBatterySlot?,
|
||||
now: Instant,
|
||||
): CachedBatterySlot? {
|
||||
if (livePercent == null) return existing
|
||||
if (existing == null) return CachedBatterySlot(livePercent, now)
|
||||
|
||||
val isStale = Duration.between(existing.updatedAt, now).abs() > Duration.ofMinutes(1)
|
||||
return if (existing.percent == livePercent && !isStale) existing else CachedBatterySlot(livePercent, now)
|
||||
if (!isKnownBattery(livePercent)) return existing
|
||||
val current = existing ?: return CachedBatterySlot(livePercent, now)
|
||||
val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1)
|
||||
return if (current.percent == livePercent && !isStale) current else CachedBatterySlot(livePercent, now)
|
||||
}
|
||||
|
||||
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
||||
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
|
||||
if (hasSlotTimestampChanged(old.left, new.left)) return true
|
||||
if (hasSlotTimestampChanged(old.right, new.right)) return true
|
||||
if (hasSlotTimestampChanged(old.case, new.case)) return true
|
||||
if (hasSlotTimestampChanged(old.headset, new.headset)) return true
|
||||
return old.left?.percent != new.left?.percent
|
||||
|| old.right?.percent != new.right?.percent
|
||||
|| old.case?.percent != new.case?.percent
|
||||
|| old.headset?.percent != new.headset?.percent
|
||||
|| old.isLeftCharging != new.isLeftCharging
|
||||
if (hasSlotChanged(old.left, new.left)) return true
|
||||
if (hasSlotChanged(old.right, new.right)) return true
|
||||
if (hasSlotChanged(old.case, new.case)) return true
|
||||
if (hasSlotChanged(old.headset, new.headset)) return true
|
||||
return old.isLeftCharging != new.isLeftCharging
|
||||
|| old.isRightCharging != new.isRightCharging
|
||||
|| old.isCaseCharging != new.isCaseCharging
|
||||
|| old.isHeadsetCharging != new.isHeadsetCharging
|
||||
|| old.deviceName != new.deviceName
|
||||
|| old.serialNumber != new.serialNumber
|
||||
|| old.firmwareVersion != new.firmwareVersion
|
||||
|| old.leftEarbudSerial != new.leftEarbudSerial
|
||||
|| old.rightEarbudSerial != new.rightEarbudSerial
|
||||
|| old.marketingVersion != new.marketingVersion
|
||||
}
|
||||
|
||||
private fun hasSlotTimestampChanged(
|
||||
old: CachedBatterySlot?,
|
||||
new: CachedBatterySlot?,
|
||||
): Boolean {
|
||||
if (old == null || new == null) return false
|
||||
private fun hasSlotChanged(old: CachedBatterySlot?, new: CachedBatterySlot?): Boolean {
|
||||
if (old == null && new == null) return false
|
||||
if (old == null || new == null) return true
|
||||
if (old.percent != new.percent) return true
|
||||
return Duration.between(old.updatedAt, new.updatedAt).abs() > Duration.ofMinutes(1)
|
||||
}
|
||||
|
||||
@@ -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.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -150,9 +151,7 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
setTextViewText(R.id.device, device.getLabel(context))
|
||||
}
|
||||
|
||||
private fun percentToInt(percent: Float?): Int {
|
||||
if (percent == null) return 0
|
||||
return (percent * 100).roundToInt().coerceIn(0, 100)
|
||||
}
|
||||
private fun percentToInt(percent: Float): Int =
|
||||
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
|
||||
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
@@ -37,7 +39,9 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
@@ -52,10 +56,13 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasToneVolume = true,
|
||||
hasEndCallMuteMic = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||
@@ -74,6 +81,7 @@ enum class PodModel(
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasToneVolume = true,
|
||||
hasEndCallMuteMic = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
hasSleepDetection = true,
|
||||
@@ -314,7 +322,6 @@ enum class PodModel(
|
||||
"Beats Solo Pro",
|
||||
R.drawable.device_beats_headphones,
|
||||
Features(
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
modelNumbers = setOf("A1881"), // headphones
|
||||
@@ -343,7 +350,6 @@ enum class PodModel(
|
||||
"Beats Studio 3",
|
||||
R.drawable.device_beats_studio3,
|
||||
Features(
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
modelNumbers = setOf("A1914"), // headphones
|
||||
@@ -410,6 +416,7 @@ enum class PodModel(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
|
||||
leftPodIconRes = R.drawable.device_powerbeats_pro_left,
|
||||
@@ -426,6 +433,7 @@ enum class PodModel(
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
hasSleepDetection = true,
|
||||
),
|
||||
@@ -444,6 +452,8 @@ enum class PodModel(
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
|
||||
leftPodIconRes = R.drawable.device_beats_fitpro_left,
|
||||
@@ -458,6 +468,7 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||
@@ -471,6 +482,7 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||
@@ -484,6 +496,7 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
||||
|
||||
@@ -16,6 +16,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -26,7 +27,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
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.
|
||||
@@ -39,6 +44,7 @@ internal class AapConnection(
|
||||
private val profile: AapDeviceProfile,
|
||||
private val socketFactory: L2capSocketFactory,
|
||||
timeSource: TimeSource,
|
||||
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
|
||||
) {
|
||||
|
||||
private val engine = AapSessionEngine(profile, timeSource)
|
||||
@@ -68,7 +74,7 @@ internal class AapConnection(
|
||||
|
||||
try {
|
||||
val sock = socketFactory.createSocket(device, PSM)
|
||||
sock.connect()
|
||||
sock.connectCancellable()
|
||||
socket = sock
|
||||
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
|
||||
|
||||
@@ -212,15 +218,47 @@ internal class AapConnection(
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
}
|
||||
socket = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PSM = 0x1001
|
||||
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
|
||||
private val TAG = logTag("AAP", "Connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,17 +21,18 @@ import kotlin.math.roundToInt
|
||||
|
||||
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 =
|
||||
percent?.let { "${(it * 100).roundToInt()}%" }
|
||||
?: context.getString(R.string.general_value_not_available_label)
|
||||
fun formatBatteryPercent(context: Context, percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
|
||||
else context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
@DrawableRes
|
||||
fun getBatteryDrawable(percent: Float?): Int = when {
|
||||
percent == null -> R.drawable.ic_baseline_battery_unknown_24
|
||||
fun getBatteryDrawable(percent: Float): Int = when {
|
||||
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
|
||||
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
|
||||
percent > 0.80f -> R.drawable.ic_baseline_battery_6_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
|
||||
}
|
||||
|
||||
fun getBatteryIcon(percent: Float?): ImageVector = when {
|
||||
percent == null -> Icons.AutoMirrored.TwoTone.BatteryUnknown
|
||||
fun getBatteryIcon(percent: Float): ImageVector = when {
|
||||
!isKnownBattery(percent) -> Icons.AutoMirrored.TwoTone.BatteryUnknown
|
||||
percent > 0.95f -> Icons.TwoTone.BatteryFull
|
||||
percent > 0.80f -> Icons.TwoTone.Battery6Bar
|
||||
percent > 0.65f -> Icons.TwoTone.Battery5Bar
|
||||
|
||||
+11
-1
@@ -44,8 +44,18 @@ data class AirPodsMax2(
|
||||
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
|
||||
get() = pubStatus.isBitSet(5)
|
||||
get() = pubStatus.isBitSet(1) || pubStatus.isBitSet(3)
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
|
||||
@@ -65,6 +65,30 @@ class DeviceProfilesRepo @Inject constructor(
|
||||
if (!settings.reactionMigrationDone.valueBlocking) {
|
||||
migrateLegacyReactions()
|
||||
}
|
||||
|
||||
detectLegacyReactionData()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether this install ever wrote to the pre-migration global reaction DataStore.
|
||||
* The legacy reader is a read-only probe; the legacy keys persist on old installs even
|
||||
* after [migrateLegacyReactions] has run, so this check works retroactively for users who
|
||||
* already migrated. Used by the Overview hint to target only existing users who actually
|
||||
* configured reactions before the per-device move.
|
||||
*/
|
||||
private suspend fun detectLegacyReactionData() {
|
||||
val hadData = try {
|
||||
val legacy = LegacyReactionSettingsReader(context, json).read()
|
||||
legacy.autoPause || legacy.autoPlay || legacy.autoConnect ||
|
||||
legacy.showPopUpOnCaseOpen || legacy.showPopUpOnConnection ||
|
||||
legacy.onePodMode
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to detect legacy reaction data: ${e.message}" }
|
||||
false
|
||||
}
|
||||
if (hadData != settings.hadLegacyReactionData.valueBlocking) {
|
||||
settings.hadLegacyReactionData.valueBlocking = hadData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +130,8 @@ class DeviceProfilesRepo @Inject constructor(
|
||||
|
||||
val profiles: Flow<List<DeviceProfile>> = settings.profiles.flow.map { it.profiles }
|
||||
|
||||
val hadLegacyReactionData: Flow<Boolean> = settings.hadLegacyReactionData.flow
|
||||
|
||||
suspend fun addProfile(profile: DeviceProfile, addFirst: Boolean = false) = mutex.withLock {
|
||||
val currentContainer = settings.profiles.valueBlocking
|
||||
checkAddressUniqueness(profile, currentContainer.profiles)
|
||||
|
||||
@@ -34,5 +34,6 @@ class DeviceProfilesSettings @Inject constructor(
|
||||
val singleToMultiMigrationDone = dataStore.createValue("profiles.migration.v2.done", false)
|
||||
val defaultProfileCreated = dataStore.createValue("profiles.default.v2.created", false)
|
||||
val reactionMigrationDone = dataStore.createValue("profiles.reactions.migration.done", false)
|
||||
val hadLegacyReactionData = dataStore.createValue("profiles.reactions.had_legacy_data", false)
|
||||
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -261,7 +262,7 @@ private fun ProfileRow(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
|
||||
@@ -2,6 +2,8 @@ package eu.darken.capod.reaction.core.playpause
|
||||
|
||||
import eu.darken.capod.common.MediaControl
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -11,13 +13,15 @@ import eu.darken.capod.common.flow.withPrevious
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||
import eu.darken.capod.reaction.core.playpause.PlayPause.Companion.PAUSE_DEBOUNCE_SAMPLES
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -30,6 +34,9 @@ class PlayPause @Inject constructor(
|
||||
|
||||
fun monitor() = run {
|
||||
var pendingPlayConfirmation: PendingPlayConfirmation? = null
|
||||
var pendingPauseDebounce: PendingPauseDebounce? = null
|
||||
var hasLastMonitorKey = false
|
||||
var lastMonitorKey: PlayPauseMonitorKey? = null
|
||||
|
||||
deviceMonitor.primaryDevice()
|
||||
.map { device -> device?.reactions?.let { it.autoPlay || it.autoPause } == true }
|
||||
@@ -39,12 +46,18 @@ class PlayPause @Inject constructor(
|
||||
bluetoothManager.connectedDevices
|
||||
} else {
|
||||
pendingPlayConfirmation = null
|
||||
pendingPauseDebounce = null
|
||||
hasLastMonitorKey = false
|
||||
lastMonitorKey = null
|
||||
emptyFlow()
|
||||
}
|
||||
}
|
||||
.flatMapLatest { connected ->
|
||||
if (connected.isEmpty()) {
|
||||
pendingPlayConfirmation = null
|
||||
pendingPauseDebounce = null
|
||||
hasLastMonitorKey = false
|
||||
lastMonitorKey = null
|
||||
log(TAG) { "No known devices connected." }
|
||||
emptyFlow()
|
||||
} else {
|
||||
@@ -53,9 +66,20 @@ class PlayPause @Inject constructor(
|
||||
}
|
||||
}
|
||||
// Cache persistence can update battery timestamps without changing any reaction-relevant state.
|
||||
.distinctUntilChangedBy { it?.toPlayPauseMonitorKey() }
|
||||
.filter { device ->
|
||||
val key = device?.toPlayPauseMonitorKey()
|
||||
val shouldEmit = !hasLastMonitorKey ||
|
||||
key != lastMonitorKey ||
|
||||
device?.isPauseDebounceResetCandidate(pendingPauseDebounce) == true
|
||||
|
||||
if (shouldEmit) {
|
||||
hasLastMonitorKey = true
|
||||
lastMonitorKey = key
|
||||
}
|
||||
shouldEmit
|
||||
}
|
||||
.onEach { device ->
|
||||
log(TAG, VERBOSE) { "Post-distinct: profileId=${device?.profileId}" }
|
||||
log(TAG, VERBOSE) { "Post-monitor-filter: profileId=${device?.profileId}" }
|
||||
}
|
||||
.withPrevious()
|
||||
.filter { (previous, current) ->
|
||||
@@ -63,7 +87,13 @@ class PlayPause @Inject constructor(
|
||||
// Use profileId (stable across BLE address rotations) rather than BLE identifier.
|
||||
// Only profiled devices reach this point (outer gate requires profile.autoPlay/autoPause).
|
||||
val match = previous.profileId != null && previous.profileId == current.profileId
|
||||
if (!match) log(TAG, WARN) { "Main device switched, skipping reaction." }
|
||||
if (!match) {
|
||||
log(TAG, WARN) { "Main device switched, skipping reaction." }
|
||||
if (pendingPauseDebounce != null) {
|
||||
log(TAG, DEBUG) { "Pause debounce reset: profile change" }
|
||||
pendingPauseDebounce = null
|
||||
}
|
||||
}
|
||||
match
|
||||
}
|
||||
.onEach { (previous, current) ->
|
||||
@@ -76,6 +106,26 @@ class PlayPause @Inject constructor(
|
||||
if (reactions == null) {
|
||||
log(TAG, VERBOSE) { "No reactions on current device, skipping reaction" }
|
||||
pendingPlayConfirmation = null
|
||||
if (pendingPauseDebounce != null) {
|
||||
log(TAG, DEBUG) { "Pause debounce reset: no reactions on device" }
|
||||
pendingPauseDebounce = null
|
||||
}
|
||||
return@onEach
|
||||
}
|
||||
|
||||
// Skip the reaction when previous was a no-live-evidence emission (cache-only
|
||||
// baseline emitted at process start, or after a >20s BLE gap that evicted the
|
||||
// device from the live cache). PodDevice.isBeingWorn returns null for those,
|
||||
// and toEarDetectionState() coerces null -> false, which would produce a
|
||||
// fake "not-worn -> worn" transition the moment live BLE arrives — firing
|
||||
// an unwanted autoPlay on app start while the user is wearing the pods.
|
||||
if (previous?.earDetectionSource() == EarDetectionSource.NO_LIVE_BLE) {
|
||||
log(TAG, VERBOSE) { "Previous emission has no live evidence; skipping reaction." }
|
||||
pendingPlayConfirmation = null
|
||||
if (pendingPauseDebounce != null) {
|
||||
log(TAG, DEBUG) { "Pause debounce reset: previous emission lacked live evidence" }
|
||||
pendingPauseDebounce = null
|
||||
}
|
||||
return@onEach
|
||||
}
|
||||
|
||||
@@ -109,6 +159,10 @@ class PlayPause @Inject constructor(
|
||||
else -> {
|
||||
log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" }
|
||||
pendingPlayConfirmation = null
|
||||
if (pendingPauseDebounce != null) {
|
||||
log(TAG, DEBUG) { "Pause debounce reset: device lost ear detection" }
|
||||
pendingPauseDebounce = null
|
||||
}
|
||||
return@onEach
|
||||
}
|
||||
}
|
||||
@@ -116,6 +170,8 @@ class PlayPause @Inject constructor(
|
||||
val isCurrentlyPlaying = mediaControl.isPlaying
|
||||
val wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap
|
||||
|
||||
val source = current.earDetectionSource()
|
||||
|
||||
// Evaluate what action to take
|
||||
val rawDecision = evaluatePlayPauseAction(
|
||||
previous = prevState,
|
||||
@@ -125,11 +181,18 @@ class PlayPause @Inject constructor(
|
||||
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
|
||||
)
|
||||
|
||||
// BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources.
|
||||
// Trusted sources (AAP, BLE_IRK_MATCH) skip staging — symmetric to the
|
||||
// pause debounce, which also skips for these sources. Without this gate,
|
||||
// an IRK-matched device with no live AAP would stage a BLE-only confirmation
|
||||
// that never confirms (the second worn sample has no freshness on the
|
||||
// monitor key for IRK_MATCH so it gets collapsed).
|
||||
val shouldStageBleOnlyPlay = rawDecision.shouldPlay &&
|
||||
reactions.autoPlay &&
|
||||
!reactions.onePodMode &&
|
||||
current.hasDualPods &&
|
||||
current.aap?.aapEarDetection == null
|
||||
(source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
|
||||
source == EarDetectionSource.BLE_ANONYMOUS)
|
||||
|
||||
val confirmation = applyBleOnlyPlayConfirmation(
|
||||
pending = pendingPlayConfirmation,
|
||||
@@ -151,7 +214,36 @@ class PlayPause @Inject constructor(
|
||||
log(TAG, VERBOSE) { "BLE-only autoplay confirmed by a follow-up state update" }
|
||||
}
|
||||
|
||||
val decision = confirmation.decision
|
||||
val debounceResult = applyPauseDebounce(
|
||||
pending = pendingPauseDebounce,
|
||||
profileId = current.profileId,
|
||||
source = source,
|
||||
rawDecision = confirmation.decision,
|
||||
currentState = currState,
|
||||
autoPauseEnabled = reactions.autoPause,
|
||||
)
|
||||
pendingPauseDebounce = debounceResult.pending
|
||||
|
||||
when (debounceResult.event) {
|
||||
PauseDebounceEvent.STARTED -> log(TAG, DEBUG) {
|
||||
"Pause debounce started: source=$source, initialPodCount=${debounceResult.pending?.initialPodCount}, " +
|
||||
"remaining=${debounceResult.pending?.confirmationsRemaining}"
|
||||
}
|
||||
PauseDebounceEvent.ADVANCED -> log(TAG, DEBUG) {
|
||||
"Pause debounce advanced: remaining=${debounceResult.pending?.confirmationsRemaining}, " +
|
||||
"currentPodCount=${currState.podCount}"
|
||||
}
|
||||
PauseDebounceEvent.RESET -> log(TAG, DEBUG) {
|
||||
"Pause debounce reset: source=$source, currentPodCount=${currState.podCount}, " +
|
||||
"rawShouldPlay=${confirmation.decision.shouldPlay}"
|
||||
}
|
||||
PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) {
|
||||
"Pause debounce committed: source=$source confirmed pause"
|
||||
}
|
||||
PauseDebounceEvent.NONE -> {}
|
||||
}
|
||||
|
||||
val decision = debounceResult.decision
|
||||
if (decision.usedRecentCapPauseOverride) {
|
||||
log(TAG, VERBOSE) {
|
||||
"Resume override: recent CAP pause window is active, allowing play despite playing=true"
|
||||
@@ -171,8 +263,15 @@ class PlayPause @Inject constructor(
|
||||
}
|
||||
|
||||
decision.shouldPause && reactions.autoPause -> {
|
||||
log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" }
|
||||
mediaControl.sendPause()
|
||||
val pauseSent = mediaControl.sendPause()
|
||||
log(TAG, INFO) {
|
||||
"autoPause triggered: source=$source, " +
|
||||
"wasWorn=${prevState.bothInEar}, isWorn=${currState.bothInEar}, " +
|
||||
"podCount=${prevState.podCount}->${currState.podCount}, " +
|
||||
"aapEar=${current.aap?.aapEarDetection != null}, " +
|
||||
"aapConn=${current.aap?.connectionState}, " +
|
||||
"pauseSent=$pauseSent, reason=${decision.reason}"
|
||||
}
|
||||
}
|
||||
|
||||
decision.shouldPause && !reactions.autoPause -> {
|
||||
@@ -319,6 +418,144 @@ class PlayPause @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample-count debounce for pause decisions when the ear-detection source is an
|
||||
* unauthenticated BLE advertisement.
|
||||
*
|
||||
* RF interference can produce a single corrupt advert that decodes as not-worn,
|
||||
* triggering a false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires
|
||||
* 3 consecutive not-worn samples before firing.
|
||||
*
|
||||
* The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause]
|
||||
* — subsequent samples after the initial detection are not-worn → not-worn, and
|
||||
* [evaluateNormalMode] returns no action for those.
|
||||
*
|
||||
* Confirmation rule: a sample with `currentState.podCount <= pending.initialPodCount`
|
||||
* counts as a confirmation. An *increase* (pod returned) clears pending.
|
||||
*
|
||||
* Trusted sources ([EarDetectionSource.AAP], [EarDetectionSource.BLE_IRK_MATCH]) skip
|
||||
* debounce and pass through [rawDecision] unchanged; any active [pending] is cleared.
|
||||
*/
|
||||
internal fun applyPauseDebounce(
|
||||
pending: PendingPauseDebounce?,
|
||||
profileId: String?,
|
||||
source: EarDetectionSource,
|
||||
rawDecision: PlayPauseDecision,
|
||||
currentState: EarDetectionState,
|
||||
autoPauseEnabled: Boolean,
|
||||
): PauseDebounceResult {
|
||||
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
|
||||
source == EarDetectionSource.BLE_ANONYMOUS
|
||||
|
||||
val activePending = pending?.takeIf { it.profileId == profileId }
|
||||
|
||||
// NO_LIVE_BLE: no fresh evidence anywhere (ble == null, aap absent or no EarDetection).
|
||||
// Suppress shouldPause — a worn → not-worn transition derived from null/cached state is
|
||||
// not real removal evidence. Also clears any active pending since stale samples must not
|
||||
// advance confirmations.
|
||||
if (source == EarDetectionSource.NO_LIVE_BLE) {
|
||||
val event = if (activePending != null) PauseDebounceEvent.RESET else PauseDebounceEvent.NONE
|
||||
return PauseDebounceResult(
|
||||
decision = rawDecision.copy(
|
||||
shouldPause = false,
|
||||
reason = "${rawDecision.reason} (suppressed: no live BLE evidence)",
|
||||
),
|
||||
pending = null,
|
||||
event = event,
|
||||
)
|
||||
}
|
||||
|
||||
// Non-debounce-eligible trusted sources (AAP / BLE_IRK_MATCH) and disabled debounce:
|
||||
// pass raw decision through and clear any active pending. Special case: if a
|
||||
// pending was active and the trusted source still shows the not-worn condition
|
||||
// (currentState.podCount <= initialPodCount and no play decision), commit the
|
||||
// pause now — the trusted source corroborates what BLE-debounce was waiting on.
|
||||
if (!needsDebounce || !autoPauseEnabled || profileId == null) {
|
||||
if (activePending != null && autoPauseEnabled &&
|
||||
currentState.podCount <= activePending.initialPodCount &&
|
||||
!rawDecision.shouldPlay
|
||||
) {
|
||||
return PauseDebounceResult(
|
||||
decision = PlayPauseDecision(
|
||||
shouldPlay = false,
|
||||
shouldPause = true,
|
||||
reason = "Pause confirmed by trusted source ($source) after BLE-debounce",
|
||||
),
|
||||
pending = null,
|
||||
event = PauseDebounceEvent.COMMITTED,
|
||||
)
|
||||
}
|
||||
val event = if (activePending != null) PauseDebounceEvent.RESET else PauseDebounceEvent.NONE
|
||||
return PauseDebounceResult(decision = rawDecision, pending = null, event = event)
|
||||
}
|
||||
|
||||
// First detection: no active pending, raw decision wants to pause → start debounce.
|
||||
if (activePending == null) {
|
||||
if (!rawDecision.shouldPause) {
|
||||
return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.NONE)
|
||||
}
|
||||
if (PAUSE_DEBOUNCE_SAMPLES <= 0) {
|
||||
return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.NONE)
|
||||
}
|
||||
return PauseDebounceResult(
|
||||
decision = rawDecision.copy(
|
||||
shouldPause = false,
|
||||
reason = "${rawDecision.reason} (debouncing, $PAUSE_DEBOUNCE_SAMPLES confirmation(s) needed)",
|
||||
),
|
||||
pending = PendingPauseDebounce(
|
||||
profileId = profileId,
|
||||
initialPodCount = currentState.podCount,
|
||||
confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES,
|
||||
),
|
||||
event = PauseDebounceEvent.STARTED,
|
||||
)
|
||||
}
|
||||
|
||||
// Confirmation phase: pending exists.
|
||||
// Reset cases — checked in order of authority:
|
||||
// 1. Raw decision wants to play → genuine play signal, reset immediately.
|
||||
// 2. Pod count went up → tolerate one rebound sample (corrupt count-up
|
||||
// protection), reset only on the second consecutive count-up.
|
||||
if (rawDecision.shouldPlay) {
|
||||
return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.RESET)
|
||||
}
|
||||
if (currentState.podCount > activePending.initialPodCount) {
|
||||
if (activePending.resetTolerance > 0) {
|
||||
return PauseDebounceResult(
|
||||
decision = rawDecision.copy(
|
||||
shouldPause = false,
|
||||
reason = "Debouncing pause (rebound tolerated)",
|
||||
),
|
||||
pending = activePending.copy(resetTolerance = activePending.resetTolerance - 1),
|
||||
event = PauseDebounceEvent.ADVANCED,
|
||||
)
|
||||
}
|
||||
return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.RESET)
|
||||
}
|
||||
|
||||
// Confirmation: count <= initialPodCount, decrement remaining.
|
||||
val remaining = activePending.confirmationsRemaining - 1
|
||||
if (remaining <= 0) {
|
||||
return PauseDebounceResult(
|
||||
decision = PlayPauseDecision(
|
||||
shouldPlay = false,
|
||||
shouldPause = true,
|
||||
reason = "Debounced pause confirmed (initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
|
||||
),
|
||||
pending = null,
|
||||
event = PauseDebounceEvent.COMMITTED,
|
||||
)
|
||||
}
|
||||
return PauseDebounceResult(
|
||||
decision = rawDecision.copy(
|
||||
shouldPause = false,
|
||||
reason = "Debouncing pause ($remaining confirmation(s) remaining)",
|
||||
),
|
||||
pending = activePending.copy(confirmationsRemaining = remaining),
|
||||
event = PauseDebounceEvent.ADVANCED,
|
||||
)
|
||||
}
|
||||
|
||||
data class EarDetectionState(
|
||||
val leftInEar: Boolean?, // null for single pod devices
|
||||
val rightInEar: Boolean?, // null for single pod devices
|
||||
@@ -389,6 +626,46 @@ class PlayPause @Inject constructor(
|
||||
val stagedConfirmation: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Trust classification for the source of the current ear-detection reading.
|
||||
*
|
||||
* - [AAP]: EarDetection setting from an active AAP (L2CAP) session — error-corrected,
|
||||
* identity-authenticated. Trusted; debounce skipped.
|
||||
* - [BLE_IRK_MATCH]: BLE advertisement whose RPA was verified against this profile's
|
||||
* identity key. Identity-authenticated; debounce skipped.
|
||||
* - [BLE_PROFILE_FALLBACK]: BLE advertisement assigned to a profile via signal-quality
|
||||
* fallback (no IRK match). Could be a stray advert from a nearby pair. Debounced.
|
||||
* - [BLE_ANONYMOUS]: BLE advertisement with no profile match. Filtered out by
|
||||
* [primaryDevice] in production (devices without profileId are dropped before
|
||||
* reaching the reaction layer); kept here defensively in case the upstream filter
|
||||
* changes. Note: [PendingPauseDebounce] is profile-keyed, so a null profileId can
|
||||
* never sustain pending state even if this branch is hit.
|
||||
* - [NO_LIVE_BLE]: No live BLE snapshot at all (cache-only or empty). Not debounced —
|
||||
* the cached state is not "fresh evidence" so it must not advance the debounce counter.
|
||||
* Any active pending is cleared.
|
||||
*/
|
||||
enum class EarDetectionSource { AAP, BLE_IRK_MATCH, BLE_PROFILE_FALLBACK, BLE_ANONYMOUS, NO_LIVE_BLE }
|
||||
|
||||
data class PendingPauseDebounce(
|
||||
val profileId: String,
|
||||
val initialPodCount: Int,
|
||||
val confirmationsRemaining: Int,
|
||||
// Tolerates one count-up rebound sample before the pending is reset. Mirrors the
|
||||
// count-down debounce on the pause side: a single corrupt advert that briefly
|
||||
// shows a pod returning shouldn't kill the pending, since the next sample may
|
||||
// confirm the pods are still out.
|
||||
val resetTolerance: Int = 1,
|
||||
)
|
||||
|
||||
/** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */
|
||||
enum class PauseDebounceEvent { NONE, STARTED, ADVANCED, RESET, COMMITTED }
|
||||
|
||||
data class PauseDebounceResult(
|
||||
val decision: PlayPauseDecision,
|
||||
val pending: PendingPauseDebounce?,
|
||||
val event: PauseDebounceEvent = PauseDebounceEvent.NONE,
|
||||
)
|
||||
|
||||
internal data class PlayPauseMonitorKey(
|
||||
val profileId: String?,
|
||||
val autoPlay: Boolean,
|
||||
@@ -402,10 +679,44 @@ class PlayPause @Inject constructor(
|
||||
val isEitherPodInEar: Boolean?,
|
||||
val hasAapEarDetection: Boolean,
|
||||
val hasBleSnapshot: Boolean,
|
||||
val source: EarDetectionSource,
|
||||
// Set only for debounce-eligible sources (BLE_PROFILE_FALLBACK / BLE_ANONYMOUS) so
|
||||
// that monitor distinct filtering doesn't collapse repeated identical not-worn samples
|
||||
// and the debounce counter can advance.
|
||||
//
|
||||
// Caveat: BlePodMonitor.preferCaseContextPod can keep an existing case-context
|
||||
// snapshot in place over an incoming non-case-context snapshot, preserving the old
|
||||
// seenLastAt. This is fail-closed (delays a legitimate unauthenticated-BLE pause
|
||||
// rather than firing a false one), so accepted as a trade-off.
|
||||
val debounceFreshness: Instant?,
|
||||
)
|
||||
|
||||
internal fun PodDevice.toPlayPauseMonitorKey(): PlayPauseMonitorKey =
|
||||
PlayPauseMonitorKey(
|
||||
internal fun PodDevice.earDetectionSource(): EarDetectionSource {
|
||||
if (aap?.aapEarDetection != null) return EarDetectionSource.AAP
|
||||
if (ble == null) return EarDetectionSource.NO_LIVE_BLE
|
||||
val applePod = ble as? ApplePods
|
||||
return when {
|
||||
applePod?.meta?.isIRKMatch == true -> EarDetectionSource.BLE_IRK_MATCH
|
||||
ble.meta.profile != null -> EarDetectionSource.BLE_PROFILE_FALLBACK
|
||||
else -> EarDetectionSource.BLE_ANONYMOUS
|
||||
}
|
||||
}
|
||||
|
||||
internal fun PodDevice.toPlayPauseMonitorKey(): PlayPauseMonitorKey {
|
||||
val source = earDetectionSource()
|
||||
// Freshness applies to all unauthenticated samples (both worn and not-worn) so
|
||||
// that monitor distinct filtering doesn't collapse repeated identical samples:
|
||||
// - not-worn samples must pass through to advance the pause debounce counter
|
||||
// - worn samples must pass through to satisfy applyBleOnlyPlayConfirmation,
|
||||
// which requires a 2nd identical worn sample to confirm a staged play
|
||||
// (see commit 6825abaa "Guard BLE-only autoplay")
|
||||
// The 2-sample autoplay confirmation IS the debounce on the play side, mirroring
|
||||
// the pause debounce. isPauseDebounceResetCandidate() remains as a defense-in-depth
|
||||
// backstop for the rare case where seenLastAt doesn't advance between samples
|
||||
// (e.g. BlePodMonitor.preferCaseContextPod preserves the prior snapshot).
|
||||
val needsFreshness = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
|
||||
source == EarDetectionSource.BLE_ANONYMOUS
|
||||
return PlayPauseMonitorKey(
|
||||
profileId = profileId,
|
||||
autoPlay = reactions.autoPlay,
|
||||
autoPause = reactions.autoPause,
|
||||
@@ -418,20 +729,45 @@ class PlayPause @Inject constructor(
|
||||
isEitherPodInEar = isEitherPodInEar,
|
||||
hasAapEarDetection = aap?.aapEarDetection != null,
|
||||
hasBleSnapshot = ble != null,
|
||||
source = source,
|
||||
debounceFreshness = if (needsFreshness) ble?.seenLastAt else null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun PodDevice.isPauseDebounceResetCandidate(pending: PendingPauseDebounce?): Boolean {
|
||||
val activePending = pending?.takeIf { it.profileId == profileId } ?: return false
|
||||
val source = earDetectionSource()
|
||||
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
|
||||
source == EarDetectionSource.BLE_ANONYMOUS
|
||||
|
||||
if (!needsDebounce || !hasEarDetection) return false
|
||||
|
||||
return toEarDetectionState().podCount > activePending.initialPodCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a dual-pod [PodDevice] to [EarDetectionState] for reaction evaluation.
|
||||
* Prefers per-side values (left/right) when available, falls back to AAP aggregate
|
||||
* state (isBeingWorn/isEitherPodInEar) when per-side mapping is unknown.
|
||||
*
|
||||
* When AAP EarDetection is present, prefers AAP aggregate state — even if per-side
|
||||
* (left/right) values are non-null via BLE fallback. This avoids letting BLE per-side
|
||||
* bits drive the decision when AAP has authoritative aggregate state but
|
||||
* resolvedPrimaryPod is unknown (cmd 0x0008 not received or cleared by role swap).
|
||||
*
|
||||
* When AAP is absent, prefers per-side values (BLE) when available, falling back to
|
||||
* AAP aggregate as a last resort (which is identical to BLE aggregate in this case).
|
||||
*/
|
||||
private fun PodDevice.toEarDetectionState(): EarDetectionState {
|
||||
internal fun PodDevice.toEarDetectionState(): EarDetectionState {
|
||||
if (aap?.aapEarDetection != null) {
|
||||
return EarDetectionState.fromAapAggregate(
|
||||
isBeingWorn = isBeingWorn ?: false,
|
||||
isEitherPodInEar = isEitherPodInEar ?: false,
|
||||
)
|
||||
}
|
||||
val left = isLeftInEar
|
||||
val right = isRightInEar
|
||||
if (left != null && right != null) {
|
||||
return EarDetectionState.fromDualPod(left = left, right = right)
|
||||
}
|
||||
// Per-side unavailable (resolvedPrimaryPod is null) — use aggregate AAP state.
|
||||
return EarDetectionState.fromAapAggregate(
|
||||
isBeingWorn = isBeingWorn ?: false,
|
||||
isEitherPodInEar = isEitherPodInEar ?: false,
|
||||
@@ -440,5 +776,11 @@ class PlayPause @Inject constructor(
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Reaction", "PlayPause")
|
||||
|
||||
/**
|
||||
* Number of additional confirmations required before an unauthenticated-BLE pause
|
||||
* decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total.
|
||||
*/
|
||||
internal const val PAUSE_DEBOUNCE_SAMPLES = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.ble.formatBatteryPercent
|
||||
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
|
||||
fun PopUpContent(
|
||||
@@ -115,7 +113,7 @@ private fun DualPodContent(device: PodDevice) {
|
||||
// Left pod
|
||||
BatteryColumn(
|
||||
iconRes = device.leftPodIcon,
|
||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
||||
batteryPercent = device.batteryLeft,
|
||||
isCharging = device.isLeftPodCharging ?: false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -124,7 +122,7 @@ private fun DualPodContent(device: PodDevice) {
|
||||
if (device.hasCase) {
|
||||
BatteryColumn(
|
||||
iconRes = device.caseIcon,
|
||||
batteryPercent = device.batteryCase.toBatteryFloat(),
|
||||
batteryPercent = device.batteryCase,
|
||||
isCharging = device.isCaseCharging ?: false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -133,7 +131,7 @@ private fun DualPodContent(device: PodDevice) {
|
||||
// Right pod
|
||||
BatteryColumn(
|
||||
iconRes = device.rightPodIcon,
|
||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
||||
batteryPercent = device.batteryRight,
|
||||
isCharging = device.isRightPodCharging ?: false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -144,7 +142,7 @@ private fun DualPodContent(device: PodDevice) {
|
||||
private fun SinglePodContent(device: PodDevice) {
|
||||
BatteryColumn(
|
||||
iconRes = device.iconRes,
|
||||
batteryPercent = device.batteryHeadset.toBatteryFloat(),
|
||||
batteryPercent = device.batteryHeadset,
|
||||
isCharging = device.isHeadsetBeingCharged ?: false,
|
||||
)
|
||||
}
|
||||
@@ -157,7 +155,6 @@ private fun BatteryColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val nullablePercent = batteryPercent.toBatteryOrNull()
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
@@ -184,14 +181,14 @@ private fun BatteryColumn(
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = getBatteryIcon(nullablePercent),
|
||||
imageVector = getBatteryIcon(batteryPercent),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = formatBatteryPercent(context, nullablePercent),
|
||||
text = formatBatteryPercent(context, batteryPercent),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
@@ -16,6 +16,7 @@ import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.monitor.core.ble.BleScanModeController
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
|
||||
@@ -40,6 +41,7 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val blePodMonitor: BlePodMonitor,
|
||||
private val bleScanModeController: BleScanModeController,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val debugSettings: DebugSettings,
|
||||
private val timeSource: TimeSource,
|
||||
@@ -84,39 +86,119 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
fun troubleShootBle() = launch(context = dispatcherProvider.IO) {
|
||||
log(TAG, INFO) { "troubleShootBle()" }
|
||||
|
||||
generalSettings.scannerMode.valueBlocking = ScannerMode.LOW_LATENCY
|
||||
|
||||
run {
|
||||
progress("Checking for headphones...")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
|
||||
run {
|
||||
progress("Checking for headphones...")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
if (mainDevice != null) {
|
||||
success("Headphones found, nothing to troubleshoot.")
|
||||
return@override
|
||||
} else {
|
||||
progress("Headphones not detected.\n")
|
||||
}
|
||||
}
|
||||
if (mainDevice != null) {
|
||||
success("Headphones found, nothing to troubleshoot.")
|
||||
return@launch
|
||||
} else {
|
||||
progress("Headphones not detected.\n")
|
||||
|
||||
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<BlePodSnapshot> =
|
||||
{ hardwareFilteringDisabled,
|
||||
hardwareBatchingDisabled,
|
||||
indirectCallback,
|
||||
unfiltered ->
|
||||
val sb = StringBuilder("SCAN - Settings: ")
|
||||
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
|
||||
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
|
||||
sb.append("indirectCallback=$indirectCallback, ")
|
||||
sb.append("unfiltered=$unfiltered")
|
||||
progress(sb.toString())
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
|
||||
debugSettings.showUnfiltered.valueBlocking = unfiltered
|
||||
|
||||
val start = timeSource.elapsedRealtime()
|
||||
val devices = withTimeoutOrNull(STEP_TIME) {
|
||||
blePodMonitor.devices
|
||||
.take(10)
|
||||
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
|
||||
.toList()
|
||||
.flatten()
|
||||
.distinctBy { it.address }
|
||||
} ?: emptyList()
|
||||
log(TAG) { "SCAN: BLE Devices: $devices" }
|
||||
if (devices.isNotEmpty()) {
|
||||
progress("SCAN: Received data from ${devices.size} BLE devices")
|
||||
devices
|
||||
} else {
|
||||
progress("SCAN: No data received")
|
||||
devices
|
||||
}
|
||||
}
|
||||
|
||||
run {
|
||||
progress("Checking if we can receive BLE data at all.")
|
||||
if (doScan(false, false, false, true).isNotEmpty()) return@run
|
||||
if (doScan(false, false, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, true, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, true, false, true).isNotEmpty()) return@run
|
||||
if (doScan(true, false, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, false, false, true).isNotEmpty()) return@run
|
||||
if (doScan(false, true, true, true).isNotEmpty()) return@run
|
||||
if (doScan(false, true, false, true).isNotEmpty()) return@run
|
||||
|
||||
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
|
||||
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = false
|
||||
debugSettings.showUnfiltered.valueBlocking = false
|
||||
|
||||
return@override
|
||||
}
|
||||
}
|
||||
|
||||
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<BlePodSnapshot> =
|
||||
{ hardwareFilteringDisabled,
|
||||
hardwareBatchingDisabled,
|
||||
indirectCallback,
|
||||
unfiltered ->
|
||||
val sb = StringBuilder("SCAN - Settings: ")
|
||||
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
|
||||
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
|
||||
sb.append("indirectCallback=$indirectCallback, ")
|
||||
sb.append("unfiltered=$unfiltered")
|
||||
progress(sb.toString())
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
|
||||
debugSettings.showUnfiltered.valueBlocking = unfiltered
|
||||
progress("We received at least some BLE data.\n")
|
||||
|
||||
val start = timeSource.elapsedRealtime()
|
||||
val devices = withTimeoutOrNull(STEP_TIME) {
|
||||
run {
|
||||
progress("Checking for supported headphones.")
|
||||
|
||||
if (doScan(false, false, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, false, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, true, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, true, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, false, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, false, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, true, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, true, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
|
||||
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
|
||||
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = false
|
||||
|
||||
return@override
|
||||
}
|
||||
|
||||
progress("Found some headphones that are supported by CAPod.\n")
|
||||
|
||||
run {
|
||||
progress("Checking for your headphones with new BLE settings...")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
if (mainDevice != null) {
|
||||
success("Found your headphones, new BLE settings worked :)!")
|
||||
return@override
|
||||
}
|
||||
}
|
||||
|
||||
progress("Still no headphones detected that count as yours.\n")
|
||||
|
||||
run {
|
||||
progress("Checking all closeby headphones.")
|
||||
|
||||
val otherDevices = withTimeoutOrNull(STEP_TIME) {
|
||||
val start = timeSource.elapsedRealtime()
|
||||
blePodMonitor.devices
|
||||
.take(10)
|
||||
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
|
||||
@@ -124,122 +206,40 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
.flatten()
|
||||
.distinctBy { it.address }
|
||||
} ?: emptyList()
|
||||
log(TAG) { "SCAN: BLE Devices: $devices" }
|
||||
if (devices.isNotEmpty()) {
|
||||
progress("SCAN: Received data from ${devices.size} BLE devices")
|
||||
devices
|
||||
} else {
|
||||
progress("SCAN: No data received")
|
||||
devices
|
||||
|
||||
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
|
||||
|
||||
if (otherDevices.isEmpty()) {
|
||||
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||
return@override
|
||||
}
|
||||
}
|
||||
|
||||
run {
|
||||
progress("Checking if we can receive BLE data at all.")
|
||||
if (doScan(false, false, false, true).isNotEmpty()) return@run
|
||||
if (doScan(false, false, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, true, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, true, false, true).isNotEmpty()) return@run
|
||||
if (doScan(true, false, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, false, false, true).isNotEmpty()) return@run
|
||||
if (doScan(false, true, true, true).isNotEmpty()) return@run
|
||||
if (doScan(false, true, false, true).isNotEmpty()) return@run
|
||||
progress("Headphones found nearby, but not detected as yours.\n")
|
||||
progress("Creating profile for closest headphones.")
|
||||
|
||||
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
|
||||
val candidate = otherDevices
|
||||
.filter { it !is UnknownSnapshotBle }
|
||||
.maxBy { it.signalQuality }
|
||||
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = false
|
||||
debugSettings.showUnfiltered.valueBlocking = false
|
||||
log(TAG, INFO) { "Candidate is $candidate" }
|
||||
|
||||
return@launch
|
||||
}
|
||||
profilesRepo.addProfile(
|
||||
profile = AppleDeviceProfile(
|
||||
label = context.getString(R.string.troubleshooter_title),
|
||||
model = candidate.model,
|
||||
),
|
||||
addFirst = true,
|
||||
)
|
||||
|
||||
progress("We received at least some BLE data.\n")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
|
||||
run {
|
||||
progress("Checking for supported headphones.")
|
||||
|
||||
if (doScan(false, false, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, false, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, true, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, true, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, false, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, false, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, true, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, true, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
|
||||
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
|
||||
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = false
|
||||
|
||||
return@launch
|
||||
}
|
||||
|
||||
progress("Found some headphones that are supported by CAPod.\n")
|
||||
|
||||
run {
|
||||
progress("Checking for your headphones with new BLE settings...")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
if (mainDevice != null) {
|
||||
success("Found your headphones, new BLE settings worked :)!")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
progress("Still no headphones detected that count as yours.\n")
|
||||
|
||||
run {
|
||||
progress("Checking all closeby headphones.")
|
||||
|
||||
val otherDevices = withTimeoutOrNull(STEP_TIME) {
|
||||
val start = timeSource.elapsedRealtime()
|
||||
blePodMonitor.devices
|
||||
.take(10)
|
||||
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
|
||||
.toList()
|
||||
.flatten()
|
||||
.distinctBy { it.address }
|
||||
} ?: emptyList()
|
||||
|
||||
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
|
||||
|
||||
if (otherDevices.isEmpty()) {
|
||||
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||
return@launch
|
||||
}
|
||||
|
||||
progress("Headphones found nearby, but not detected as yours.\n")
|
||||
progress("Creating profile for closest headphones.")
|
||||
|
||||
val candidate = otherDevices
|
||||
.filter { it !is UnknownSnapshotBle }
|
||||
.maxBy { it.signalQuality }
|
||||
|
||||
log(TAG, INFO) { "Candidate is $candidate" }
|
||||
|
||||
profilesRepo.addProfile(
|
||||
profile = AppleDeviceProfile(
|
||||
label = context.getString(R.string.troubleshooter_title),
|
||||
model = candidate.model,
|
||||
),
|
||||
addFirst = true,
|
||||
)
|
||||
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
|
||||
generalSettings.scannerMode.valueBlocking = ScannerMode.BALANCED
|
||||
|
||||
if (mainDevice != null) {
|
||||
success("Success! Detected your headphones.")
|
||||
} else {
|
||||
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||
if (mainDevice != null) {
|
||||
success("Success! Detected your headphones.")
|
||||
} else {
|
||||
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Wys \'n ekstra kennisgewing wanneer \'n toestel gekoppel is. Dit laat jou toe om die permanente \"Geen toestelle\" kennisgewing te versteek deur die \"Toestelstatus\" kanaal te deaktiveer.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Hou kennisgewing ná ontkoppeling</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Hou aan om die laaste bekende batteryvlakke te wys selfs nadat jou AirPods ontkoppel.</string>
|
||||
<string name="settings_scanner_mode_label">Skandeerdermodus</string>
|
||||
<string name="settings_scanner_mode_description">Moet die Bluetooth Lae Energie-dataskandeerder werkverrigting prioritiseer of energie bespaar?</string>
|
||||
<string name="settings_autopause_label">Outo-pouse</string>
|
||||
<string name="settings_autopause_description">Pouseer oudio wanneer die toestel uit jou oor verwyder word.</string>
|
||||
<string name="settings_autopplay_label">Outo-speel</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Die volgende toestemming is vereis:</string>
|
||||
<string name="permission_system_alert_window_label">Stelselwaarskuwingsvenster</string>
|
||||
<string name="permission_system_alert_window_description">Laat CAPod toe om oor ander toepassings te teken om die kenmerk Wys opspringer moontlik te maak.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Lae krag</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Gebalanseer</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Lae latensie</string>
|
||||
<string name="settings_monitor_mode_manual_label">Wanneer toepassing oop is</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Wanneer toestel gekoppel is</string>
|
||||
<string name="settings_monitor_mode_always_label">Altyd</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dubbel druk om oproep te beëindig</string>
|
||||
<string name="device_settings_open_cd">Maak toestel-instellings oop</string>
|
||||
<string name="overview_card_missing_paired_device">Hierdie profiel het geen gekoppelde Bluetooth-toestel nie.</string>
|
||||
<string name="overview_reactions_hint_title">Reaksies is per toestel</string>
|
||||
<string name="overview_reactions_hint_body">Outo-speel, outo-pouse en opspringers word nou per toestel gekonfigureer. Tik op die instellings-ikoon op \'n kaart terwyl jou kopfone verbind is.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Algemeen</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofoon</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">አንድ መሣሪያ ሲገናኝ ተጨማሪ ማሳወቂያ ያሳያል። ይህ የ \"የመሣሪያ ሁኔታ\" ቻናልን በማሰናከል ቋሚውን \"ምንም መሣሪያዎች የሉም\" የሚለውን ማሳወቂያ እንዲደብቁ ያስችልዎታል።</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">ከተነተቀ በኋላ ማሳወቂያውን ይያዙ</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">AirPods ከተነተቁ በኋላም የመጨረሻውን የታወቀ የባትሪ ደረጃ ማሳየቱን ይቀጥሉ</string>
|
||||
<string name="settings_scanner_mode_label">የቃኚ ሁነታ</string>
|
||||
<string name="settings_scanner_mode_description">የብሉቱዝ ዝቅተኛ ኃይል ዳታ ቃኚው አፈጻጸምን ቅድሚያ መስጠት አለበት ወይስ ኃይል መቆጠብ አለበት?</string>
|
||||
<string name="settings_autopause_label">ራስ-ሰር ለአፍታ ማቆም</string>
|
||||
<string name="settings_autopause_description">መሳሪያውን ከጆሮዎ ሲያወጡ ድምጽን ለአፍታ ያቁሙ።</string>
|
||||
<string name="settings_autopplay_label">ራስ-ሰር አጫውት</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">የሚከተለው ፈቃድ ያስፈልጋል:</string>
|
||||
<string name="permission_system_alert_window_label">የስርዓት ማስጠንቀቂያ መስኮት</string>
|
||||
<string name="permission_system_alert_window_description">\"ብቅ-ባይ አሳይ\" ባህሪውን ለማስቻል CAPod በሌሎች መተግበሪያዎች ላይ እንዲሳል ፍቀድ።</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">ዝቅተኛ ኃይል</string>
|
||||
<string name="settings_scanner_mode_balanced_label">ሚዛናዊ</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">ዝቅተኛ መዘግየት</string>
|
||||
<string name="settings_monitor_mode_manual_label">መተግበሪያው ሲከፈት</string>
|
||||
<string name="settings_monitor_mode_automatic_label">መሣሪያ ሲገናኝ</string>
|
||||
<string name="settings_monitor_mode_always_label">ሁልጊዜ</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">ጥሪን ለመጨረስ ሁለት ጊዜ ጀን</string>
|
||||
<string name="device_settings_open_cd">የመሣሪያ ቅንብሮችን ክፈት</string>
|
||||
<string name="overview_card_missing_paired_device">ይህ መገለጫ የጋራ ብሉቱዝ መሳሪያ የለውም።</string>
|
||||
<string name="overview_reactions_hint_title">ምላሾች በእያንዳንዱ መሳሪያ ናቸው</string>
|
||||
<string name="overview_reactions_hint_body">የራስ-ማጫወት፣ የራስ-ማቆም እና ብቅ-ባይ መስኮቶች አሁን በእያንዳንዱ መሳሪያ ይዋቀራሉ። ጆሮ ማዳመጫዎ ሲገናኙ ካርዱ ላይ ያለውን የቅንብሮች አዶ መታ ያድርጉ።</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">አጠቃላይ</string>
|
||||
<string name="device_settings_microphone_mode_label">ማይክሮፎን</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">يعرض إشعارًا إضافيًا عند توصيل جهاز. يتيح لك هذا إخفاء الإشعار الدائم \"لا توجد أجهزة\" عن طريق تعطيل قناة \"حالة الجهاز\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">الاحتفاظ بالإشعار بعد قطع الاتصال</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">الاستمرار في عرض مستويات البطارية المعروفة الأخيرة حتى بعد قطع اتصال AirPods</string>
|
||||
<string name="settings_scanner_mode_label">وضع الماسح الضوئي</string>
|
||||
<string name="settings_scanner_mode_description">هل يجب أن يعطي ماسح بيانات البلوتوث منخفض الطاقة الأولوية للأداء أم يحافظ على الطاقة؟</string>
|
||||
<string name="settings_autopause_label">إيقاف مؤقت تلقائي</string>
|
||||
<string name="settings_autopause_description">إيقاف الصوت مؤقتًا عند إزالة الجهاز من أذنك.</string>
|
||||
<string name="settings_autopplay_label">تشغيل تلقائي</string>
|
||||
@@ -230,9 +228,6 @@
|
||||
<string name="permission_required_title">الإذن التالي مطلوب:</string>
|
||||
<string name="permission_system_alert_window_label">نافذة تنبيه النظام</string>
|
||||
<string name="permission_system_alert_window_description">السماح لـ CAPod بالرسم فوق التطبيقات الأخرى لتمكين ميزة \"إظهار النافذة المنبثقة\".</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">طاقة منخفضة</string>
|
||||
<string name="settings_scanner_mode_balanced_label">متوازن</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">تأخير منخفض</string>
|
||||
<string name="settings_monitor_mode_manual_label">عند فتح التطبيق</string>
|
||||
<string name="settings_monitor_mode_automatic_label">عند اتصال الجهاز</string>
|
||||
<string name="settings_monitor_mode_always_label">دائمًا</string>
|
||||
@@ -490,6 +485,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">ضغطة مزدوجة لإنهاء المكالمة</string>
|
||||
<string name="device_settings_open_cd">فتح إعدادات الجهاز</string>
|
||||
<string name="overview_card_missing_paired_device">هذا الملف الشخصي لا يحتوي على جهاز بلوتوث مقترن.</string>
|
||||
<string name="overview_reactions_hint_title">ردود الفعل خاصة بكل جهاز</string>
|
||||
<string name="overview_reactions_hint_body">يتم الآن تهيئة التشغيل التلقائي والإيقاف التلقائي والنوافذ المنبثقة لكل جهاز على حدة. انقر على أيقونة الإعدادات على البطاقة أثناء توصيل سماعاتك.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">عام</string>
|
||||
<string name="device_settings_microphone_mode_label">ميكروفون</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Cihaz qoşulduqda əlavə bildiriş göstərir. Bu, \"Cihaz statusu\" kanalını deaktiv etməklə daimi \"Cihaz yoxdur\" bildirişini gizlətməyə imkan verir.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Ayrıldıqdan sonra bildirişi saxla</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">AirPods ayrıldıqdan sonra belə sonuncu bilinən batareya səviyyələrini göstərməyə davam et</string>
|
||||
<string name="settings_scanner_mode_label">Skaner rejimi</string>
|
||||
<string name="settings_scanner_mode_description">Bluetooth Aşağı Enerji məlumat skaneri performansa üstünlük verməli, yoxsa enerjiyə qənaət etməlidir?</string>
|
||||
<string name="settings_autopause_label">Avtomatik fasilə</string>
|
||||
<string name="settings_autopause_description">Cihazı qulağınızdan çıxardıqda səsi dayandırın.</string>
|
||||
<string name="settings_autopplay_label">Avtomatik oxutma</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Aşağıdakı icazə tələb olunur:</string>
|
||||
<string name="permission_system_alert_window_label">Sistem xəbərdarlıq pəncərəsi</string>
|
||||
<string name="permission_system_alert_window_description">\"Pop-up göstər\" xüsusiyyətini mümkün etmək üçün CAPod-a digər tətbiqlərin üzərində çəkməyə icazə verin.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Aşağı güc</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Balanslaşdırılmış</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Aşağı gecikmə</string>
|
||||
<string name="settings_monitor_mode_manual_label">Tətbiq açıq olduqda</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Cihaz qoşulduqda</string>
|
||||
<string name="settings_monitor_mode_always_label">Həmişə</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Zəngi bitirmək üçün iki dəfə basın</string>
|
||||
<string name="device_settings_open_cd">Cihaz parametrlərini açın</string>
|
||||
<string name="overview_card_missing_paired_device">Bu profilin cütləşdirilmiş Bluetooth cihazı yoxdur.</string>
|
||||
<string name="overview_reactions_hint_title">Reaksiyalar hər cihaz üçün ayrıdır</string>
|
||||
<string name="overview_reactions_hint_body">Avtomatik oxutma, avtomatik dayandırma və açılan pəncərələr artıq hər cihaz üçün ayrıca konfiqurasiya edilir. Qulaqlıqlarınız qoşulu olarkən kartdakı parametrlər ikonasına toxunun.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Ümumi</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Паказвае дадатковае апавяшчэнне пры падключэнні прылады. Гэта дазваляе схаваць пастаяннае апавяшчэнне \"Няма прылад\" шляхам адключэння канала \"Стан прылады\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Захаваць апавяшчэнне пасля адключэння</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Працягваць паказваць апошнія вядомыя ўзроўні зарадкі нават пасля адключэння вашых AirPods</string>
|
||||
<string name="settings_scanner_mode_label">Рэжым сканера</string>
|
||||
<string name="settings_scanner_mode_description">Ці павінен сканер даных Bluetooth Low Energy аддаваць перавагу прадукцыйнасці або эканоміць энергію?</string>
|
||||
<string name="settings_autopause_label">Аўтаматычная паўза</string>
|
||||
<string name="settings_autopause_description">Прыпыняць аўдыя пры выманні прылады з вуха.</string>
|
||||
<string name="settings_autopplay_label">Аўтаматычнае прайграванне</string>
|
||||
@@ -226,9 +224,6 @@
|
||||
<string name="permission_required_title">Патрабуецца наступны дазвол:</string>
|
||||
<string name="permission_system_alert_window_label">Сістэмнае акно папярэджання</string>
|
||||
<string name="permission_system_alert_window_description">Дазвольце CAPod маляваць паверх іншых праграм, каб зрабіць магчымай функцыю \"Паказаць усплывальнае акно\".</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Нізкае энергаспажыванне</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Збалансавана</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Нізкая затрымка</string>
|
||||
<string name="settings_monitor_mode_manual_label">Калі праграма адкрыта</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Калі прылада падключана</string>
|
||||
<string name="settings_monitor_mode_always_label">Заўсёды</string>
|
||||
@@ -480,6 +475,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Двайны націск для завяршэння выкліку</string>
|
||||
<string name="device_settings_open_cd">Адкрыць налады прылады</string>
|
||||
<string name="overview_card_missing_paired_device">З гэтым профілем не сапалучана прылада Bluetooth.</string>
|
||||
<string name="overview_reactions_hint_title">Рэакцыі ўстаноўлены для кожнай прылады</string>
|
||||
<string name="overview_reactions_hint_body">Аўтаўзнаўленне, аўтапаўза і ўсплывальныя вокны цяпер наладжваюцца для кожнай прылады. Націсніце значок налад на картцы, пакуль вашы навушнікі падключаны.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Агульныя</string>
|
||||
<string name="device_settings_microphone_mode_label">Мікрафон</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Показва допълнително известие, когато устройство е свързано. Това ви позволява да скриете постоянното известие \"Няма устройства\" чрез деактивиране на канала \"Състояние на устройството\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Запази известието след прекъсване</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Продължавай да показваш последните известни нива на батерията дори след прекъсване на връзката с AirPods</string>
|
||||
<string name="settings_scanner_mode_label">Режим на скенер</string>
|
||||
<string name="settings_scanner_mode_description">Трябва ли скенерът за данни с ниска енергия на Bluetooth да даде приоритет на производителността или да пести енергия?</string>
|
||||
<string name="settings_autopause_label">Автоматична пауза</string>
|
||||
<string name="settings_autopause_description">Поставяне на аудио на пауза при премахване на устройството от ухото ви.</string>
|
||||
<string name="settings_autopplay_label">Автоматично възпроизвеждане</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Необходимо е следното разрешение:</string>
|
||||
<string name="permission_system_alert_window_label">Системен изскачащ прозорец</string>
|
||||
<string name="permission_system_alert_window_description">Позволете на CAPod да рисува върху други приложения, за да направи функцията \"Покажи изскачащ прозорец\" възможна.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Ниска мощност</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Балансиран</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Ниска латентност</string>
|
||||
<string name="settings_monitor_mode_manual_label">Когато приложението е отворено</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Когато устройството е свързано</string>
|
||||
<string name="settings_monitor_mode_always_label">Винаги</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Двойно натискане за прекратяване на разговора</string>
|
||||
<string name="device_settings_open_cd">Отворете настройките на устройството</string>
|
||||
<string name="overview_card_missing_paired_device">Този профил няма сдвоено Bluetooth устройство.</string>
|
||||
<string name="overview_reactions_hint_title">Реакциите са за всяко устройство поотделно</string>
|
||||
<string name="overview_reactions_hint_body">Автоматичното възпроизвеждане, паузирането и изскачащите прозорци вече се конфигурират за всяко устройство поотделно. Натиснете иконата с настройки на картата, докато слушалките ви са свързани.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Общи</string>
|
||||
<string name="device_settings_microphone_mode_label">Микрофон</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">একটি ডিভাইস সংযুক্ত হলে একটি অতিরিক্ত বিজ্ঞপ্তি দেখায়। এটি আপনাকে \"ডিভাইস স্ট্যাটাস\" চ্যানেল নিষ্ক্রিয় করে স্থায়ী \"কোনো ডিভাইস নেই\" বিজ্ঞপ্তি লুকাতে দেয়।</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">সংযোগ বিচ্ছিন্নের পরে বিজ্ঞপ্তি রাখুন</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">আপনার AirPods সংযোগ বিচ্ছিন্ন হওয়ার পরেও সর্বশেষ জানা ব্যাটারি স্তর দেখাতে থাকুন</string>
|
||||
<string name="settings_scanner_mode_label">স্ক্যানার মোড</string>
|
||||
<string name="settings_scanner_mode_description">ব্লুটুথ লো এনার্জি ডেটা স্ক্যানার কি কর্মক্ষমতাকে অগ্রাধিকার দেবে নাকি শক্তি সংরক্ষণ করবে?</string>
|
||||
<string name="settings_autopause_label">স্বয়ংক্রিয় বিরতি</string>
|
||||
<string name="settings_autopause_description">আপনার কান থেকে ডিভাইসটি সরানোর সময় অডিওতে বিরতি দিন।</string>
|
||||
<string name="settings_autopplay_label">স্বয়ংক্রিয় প্লে</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">নিম্নলিখিত অনুমতি প্রয়োজন:</string>
|
||||
<string name="permission_system_alert_window_label">সিস্টেম অ্যালার্ট উইন্ডো</string>
|
||||
<string name="permission_system_alert_window_description">\"পপআপ দেখান\" বৈশিষ্ট্যটি সম্ভব করতে CAPod-কে অন্যান্য অ্যাপের উপরে আঁকতে অনুমতি দিন।</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">কম শক্তি</string>
|
||||
<string name="settings_scanner_mode_balanced_label">ভারসাম্যপূর্ণ</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">কম বিলম্ব</string>
|
||||
<string name="settings_monitor_mode_manual_label">অ্যাপ খোলা থাকলে</string>
|
||||
<string name="settings_monitor_mode_automatic_label">ডিভাইস সংযুক্ত থাকলে</string>
|
||||
<string name="settings_monitor_mode_always_label">সর্বদা</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">কল শেষ করতে দুবার চাপুন</string>
|
||||
<string name="device_settings_open_cd">ডিভাইস সেটিংস খুলুন</string>
|
||||
<string name="overview_card_missing_paired_device">এই প্রোফাইলে কোনো পেয়ার করা ব্লুটুথ ডিভাইস নেই।</string>
|
||||
<string name="overview_reactions_hint_title">প্রতিক্রিয়াগুলি প্রতিটি ডিভাইসের জন্য আলাদা</string>
|
||||
<string name="overview_reactions_hint_body">অটো-প্লে, অটো-পজ এবং পপ-আপগুলি এখন প্রতিটি ডিভাইসের জন্য আলাদাভাবে কনফিগার করা হয়েছে। আপনার হেডফোন সংযুক্ত থাকাকালীন একটি কার্ডে সেটিংস আইকনে ট্যাপ করুন।</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">সাধারণ</string>
|
||||
<string name="device_settings_microphone_mode_label">মাইক্রোফোন</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Mostra una notificació addicional quan hi ha un dispositiu connectat. Això permet ocultar la notificació permanent «Sense dispositius» desactivant el canal «Estat del dispositiu».</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Mantén les notificacions després de la desconnexió</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Continua mostrant els últims nivells de bateria coneguts fins i tot després que els AirPods es desconnectin</string>
|
||||
<string name="settings_scanner_mode_label">Mode escàner</string>
|
||||
<string name="settings_scanner_mode_description">L\'escàner de dades Bluetooth de baix consum hauria de prioritzar el rendiment o estalviar energia?</string>
|
||||
<string name="settings_autopause_label">Pausa automàtica</string>
|
||||
<string name="settings_autopause_description">Pausa l\'àudio quan ús tragueu el dispositiu de l\'orella.</string>
|
||||
<string name="settings_autopplay_label">Reproducció automàtica</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Cal el permís següent:</string>
|
||||
<string name="permission_system_alert_window_label">Finestra d\'alerta del sistema</string>
|
||||
<string name="permission_system_alert_window_description">Permet que el CAPod dibuixi sobre altres aplicacions per fer possible la característica «Mostra emergent».</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Baixa potència</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Equilibrat</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Baixa latència</string>
|
||||
<string name="settings_monitor_mode_manual_label">Quan l\'aplicació està oberta</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Quan el dispositiu està connectat</string>
|
||||
<string name="settings_monitor_mode_always_label">Sempre</string>
|
||||
@@ -294,7 +289,7 @@
|
||||
<string name="profiles_delete_action">Elimina</string>
|
||||
<string name="profiles_basic_info_title">Informació del dispositiu</string>
|
||||
<string name="profiles_basic_info_description">Configureu el nom del dispositiu, el model i l\'emparellament Bluetooth opcional.</string>
|
||||
<string name="profiles_signal_quality_title">Qualitat de senyal màxima</string>
|
||||
<string name="profiles_signal_quality_title">Qualitat de senyal mínima</string>
|
||||
<string name="profiles_signal_quality_description">Només es detecten dispositius amb una intensitat de senyal superior a aquest llindar. Els valors més baixos augmenten el rang de detecció, però poden causar falsos positius. No configureu aquest valor massa alt: la recepció Bluetooth generalment és deficient i varia segons la distància i els obstacles.</string>
|
||||
<string name="profiles_identitykey_label">Clau d\'identitat</string>
|
||||
<string name="profilessettings_maindevice_identitykey_description">La clau de resolució d\'identitat (IRK) del dispositiu ajuda al CAPod a identificar-lo entre els dispositius propers.</string>
|
||||
@@ -396,7 +391,7 @@
|
||||
<string name="support_contact_send_action">Obre l\'aplicació de correu</string>
|
||||
<string name="support_contact_no_email_app">No s\'ha trobat cap aplicació de correu en aquest dispositiu.</string>
|
||||
<string name="support_contact_debuglog_delete_title">Voleu suprimir el registre de depuració?</string>
|
||||
<string name="support_contact_debuglog_delete_message">Aquest registre de depuració se suprimrirà permanentment.</string>
|
||||
<string name="support_contact_debuglog_delete_message">Aquest registre de depuració se suprimirà permanentment.</string>
|
||||
<!-- Post-share confirmation -->
|
||||
<string name="support_debuglog_sent_title">S\'ha enviat el registre?</string>
|
||||
<string name="support_debuglog_sent_message">El registre de depuració s\'ha enviat correctament? El registre s\'eliminarà.</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Doble toc per acabar la trucada</string>
|
||||
<string name="device_settings_open_cd">Obre la configuració del dispositiu</string>
|
||||
<string name="overview_card_missing_paired_device">Aquest perfil no té cap dispositiu Bluetooth emparellat.</string>
|
||||
<string name="overview_reactions_hint_title">Les reaccions són per dispositiu</string>
|
||||
<string name="overview_reactions_hint_body">La reproducció automàtica, la pausa automàtica i les finestres emergents ara es configuren per dispositiu. Toqueu la icona de configuració d\'una targeta mentre els vostres auriculars estan connectats.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">General</string>
|
||||
<string name="device_settings_microphone_mode_label">Micròfon</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Zobrazí dodatečné oznámení, když je zařízení připojeno. To umožňuje skrýt trvalé oznámení \"Žádné zařízení\" vypnutím kanálu \"Stav zařízení\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Uchovat oznámení po odpojení</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Pokračovat v zobrazování poslední známé úrovně nabití baterie i po odpojení AirPods</string>
|
||||
<string name="settings_scanner_mode_label">Režim skenování</string>
|
||||
<string name="settings_scanner_mode_description">Má skenování Bluetooth Low Energy upřednostnit výkon, nebo úsporu energie?</string>
|
||||
<string name="settings_autopause_label">Autom. pozastavení</string>
|
||||
<string name="settings_autopause_description">Pozastavení zvuku při vyjmutí zařízení z ucha.</string>
|
||||
<string name="settings_autopplay_label">Autom. přehrávání</string>
|
||||
@@ -226,9 +224,6 @@
|
||||
<string name="permission_required_title">Je vyžadováno následující oprávnění:</string>
|
||||
<string name="permission_system_alert_window_label">Okno systémových upozornění</string>
|
||||
<string name="permission_system_alert_window_description">Aby bylo možné použít unkci \"Zobrazit vyskakovací okno\", je nutné umožnit aplikaci CAPod vykreslení přes jiné aplikace.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Nízký výkon</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Vyvážený</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Nízká latence</string>
|
||||
<string name="settings_monitor_mode_manual_label">Při otevření aplikace</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Při připojení zařízení</string>
|
||||
<string name="settings_monitor_mode_always_label">Vždy</string>
|
||||
@@ -480,6 +475,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dvojitý stisk pro ukončení hovoru</string>
|
||||
<string name="device_settings_open_cd">Otevřít nastavení zařízení</string>
|
||||
<string name="overview_card_missing_paired_device">Tento profil nemá spárované zařízení Bluetooth.</string>
|
||||
<string name="overview_reactions_hint_title">Reakce jsou nastaveny pro každé zařízení</string>
|
||||
<string name="overview_reactions_hint_body">Automatické přehrávání, automatické pozastavení a vyskakovací okna jsou nyní konfigurována pro každé zařízení. Klepněte na ikonu nastavení na kartě, když jsou vaše sluchátka připojena.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Obecné</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Viser en ekstra notifikation, når en enhed er tilsluttet. Dette lader dig skjule den permanente \"Ingen enheder\"-notifikation ved at deaktivere \"Enhedsstatus\"-kanalen.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Behold notifikation efter afbrydelse</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Fortsæt med at vise de sidst kendte batteriniveauer, selv efter dine AirPods afbrydes</string>
|
||||
<string name="settings_scanner_mode_label">Scannertilstand</string>
|
||||
<string name="settings_scanner_mode_description">Skal Bluetooth Low Energy-datascanneren prioritere ydeevne eller spare på energien?</string>
|
||||
<string name="settings_autopause_label">Auto-pause</string>
|
||||
<string name="settings_autopause_description">Sæt lyd på pause, når du fjerner enheden fra dit øre.</string>
|
||||
<string name="settings_autopplay_label">Auto-afspilning</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Følgende tilladelse er påkrævet:</string>
|
||||
<string name="permission_system_alert_window_label">Systemalarmvindue</string>
|
||||
<string name="permission_system_alert_window_description">Tillad CAPod at tegne over andre apps for at gøre funktionen \"Vis pop op\" mulig.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Lavt strømforbrug</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Balanceret</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Lav latens</string>
|
||||
<string name="settings_monitor_mode_manual_label">Når appen er åben</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Når enheden er tilsluttet</string>
|
||||
<string name="settings_monitor_mode_always_label">Altid</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dobbelt tryk for at afslutte opkald</string>
|
||||
<string name="device_settings_open_cd">Åbn enhedsindstillinger</string>
|
||||
<string name="overview_card_missing_paired_device">Denne profil har ingen parret Bluetooth-enhed.</string>
|
||||
<string name="overview_reactions_hint_title">Reaktioner er per enhed</string>
|
||||
<string name="overview_reactions_hint_body">Automatisk afspilning, automatisk pause og pop-ups er nu konfigureret per enhed. Tryk på indstillingsikonet på et kort, mens dine hovedtelefoner er tilsluttet.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Generel</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Zeigt eine zusätzliche Benachrichtigung an, wenn ein Gerät verbunden ist. Dadurch kannst du die permanente Benachrichtigung „Keine Geräte“ ausblenden, indem du den Kanal „Gerätestatus“ deaktivierst.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Benachrichtigung nach Trennung beibehalten</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Zeigt weiterhin den letzten bekannten Akkustand an, auch wenn die Verbindung zu deinen AirPods getrennt wurde</string>
|
||||
<string name="settings_scanner_mode_label">Scan-Modus</string>
|
||||
<string name="settings_scanner_mode_description">Soll die Bluetooth Low Energy Überwachung Leistung priorisieren oder Energie sparen?</string>
|
||||
<string name="settings_autopause_label">Automatische Pause</string>
|
||||
<string name="settings_autopause_description">Musik pausieren, wenn der Kopfhörer vom Ohr entfernt wird.</string>
|
||||
<string name="settings_autopplay_label">Automatisches abspielen</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Die folgende Genehmigung ist erforderlich:</string>
|
||||
<string name="permission_system_alert_window_label">Systemwarnungsfenster</string>
|
||||
<string name="permission_system_alert_window_description">Erlaube CAPod, PopUp Benachrichtigungen über anderen Apps anzuzeigen.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Geringer Strom</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Ausgewogen</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Geringe Latenz</string>
|
||||
<string name="settings_monitor_mode_manual_label">Wenn die App geöffnet ist</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Wenn das Gerät verbunden ist</string>
|
||||
<string name="settings_monitor_mode_always_label">Immer</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Zweimaliges Drücken zum Beenden des Anrufs</string>
|
||||
<string name="device_settings_open_cd">Geräteeinstellungen öffnen</string>
|
||||
<string name="overview_card_missing_paired_device">Dieses Profil hat kein gekoppeltes Bluetooth-Gerät.</string>
|
||||
<string name="overview_reactions_hint_title">Reaktionen sind gerätespezifisch</string>
|
||||
<string name="overview_reactions_hint_body">Auto-Play, Auto-Pause und Pop-ups werden jetzt pro Gerät konfiguriert. Tippe auf das Einstellungssymbol einer Karte, während deine Kopfhörer verbunden sind.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Allgemein</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Εμφανίζει μια επιπλέον ειδοποίηση όταν μια συσκευή είναι συνδεδεμένη. Αυτό σας επιτρέπει να αποκρύψετε τη μόνιμη ειδοποίηση \"Δεν υπάρχουν συσκευές\" απενεργοποιώντας το κανάλι \"Κατάσταση συσκευής\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Διατήρηση ειδοποίησης μετά την αποσύνδεση</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Συνεχίστε να εμφανίζετε τα τελευταία γνωστά επίπεδα μπαταρίας ακόμα και μετά την αποσύνδεση των AirPods σας</string>
|
||||
<string name="settings_scanner_mode_label">Λειτουργία σαρωτή</string>
|
||||
<string name="settings_scanner_mode_description">Ο σαρωτής δεδομένων Bluetooth Low Energy θα πρέπει να δώσει προτεραιότητα στην απόδοση ή στην εξοικονόμηση ενέργειας;</string>
|
||||
<string name="settings_autopause_label">Αυτόματη παύση</string>
|
||||
<string name="settings_autopause_description">Παύση ήχου κατά την αφαίρεση της συσκευής από το αυτί σας.</string>
|
||||
<string name="settings_autopplay_label">Αυτόματη αναπαραγωγή</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Απαιτείται η ακόλουθη άδεια:</string>
|
||||
<string name="permission_system_alert_window_label">Παράθυρο ειδοποίησης συστήματος</string>
|
||||
<string name="permission_system_alert_window_description">Επιτρέψτε στο CAPod να σχεδιάζει πάνω από άλλες εφαρμογές για να καταστεί δυνατή η λειτουργία \"Εμφάνιση αναδυόμενου\".</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Χαμηλή ισχύς</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Ισορροπημένο</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Χαμηλή καθυστέρηση</string>
|
||||
<string name="settings_monitor_mode_manual_label">Όταν η εφαρμογή είναι ανοιχτή</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Όταν η συσκευή είναι συνδεδεμένη</string>
|
||||
<string name="settings_monitor_mode_always_label">Πάντα</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Διπλή πίεση για τερματισμό κλήσης</string>
|
||||
<string name="device_settings_open_cd">Άνοιγμα ρυθμίσεων συσκευής</string>
|
||||
<string name="overview_card_missing_paired_device">Αυτό το προφίλ δεν έχει συζευγμένη συσκευή Bluetooth.</string>
|
||||
<string name="overview_reactions_hint_title">Οι αντιδράσεις είναι ανά συσκευή</string>
|
||||
<string name="overview_reactions_hint_body">Η αυτόματη αναπαραγωγή, η αυτόματη παύση και τα αναδυόμενα παράθυρα διαμορφώνονται πλέον ανά συσκευή. Πατήστε το εικονίδιο ρυθμίσεων σε μια κάρτα ενώ τα ακουστικά σας είναι συνδεδεμένα.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Γενικά</string>
|
||||
<string name="device_settings_microphone_mode_label">Μικρόφωνο</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Muestra una notificación extra cuando un dispositivo está conectado. Esto te permite ocultar la notificación permanente \"Sin dispositivos\" desactivando el canal \"Estado del dispositivo\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Mantener la notificación después de la desconexión</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Seguir mostrando los últimos niveles de batería conocidos incluso después de que tus AirPods se desconecten</string>
|
||||
<string name="settings_scanner_mode_label">Modo escáner</string>
|
||||
<string name="settings_scanner_mode_description">¿Debería el escáner de datos de Bluetooth de baja energía priorizar el rendimiento o conservar energía?</string>
|
||||
<string name="settings_autopause_label">Pausa automática</string>
|
||||
<string name="settings_autopause_description">Pausar el audio al quitar el dispositivo de tu oreja.</string>
|
||||
<string name="settings_autopplay_label">Reproducción automática</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Se requiere el siguiente permiso:</string>
|
||||
<string name="permission_system_alert_window_label">Ventana emergente</string>
|
||||
<string name="permission_system_alert_window_description">Permite que CAPod se superponga a otras aplicaciones para que la función «Mostrar ventana emergente».</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Baja energía</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Equilibrado</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Baja latencia</string>
|
||||
<string name="settings_monitor_mode_manual_label">Cuando la aplicación está abierta</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Cuando el dispositivo está conectado</string>
|
||||
<string name="settings_monitor_mode_always_label">Siempre</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Doble pulsación para finalizar la llamada</string>
|
||||
<string name="device_settings_open_cd">Abrir configuración del dispositivo</string>
|
||||
<string name="overview_card_missing_paired_device">Este perfil no tiene ningún dispositivo Bluetooth emparejado.</string>
|
||||
<string name="overview_reactions_hint_title">Las reacciones son por dispositivo</string>
|
||||
<string name="overview_reactions_hint_body">La reproducción automática, la pausa automática y las ventanas emergentes ahora se configuran por dispositivo. Tocá el ícono de configuración en una tarjeta mientras tus auriculares estén conectados.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">General</string>
|
||||
<string name="device_settings_microphone_mode_label">Micrófono</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Muestra una notificación adicional cuando un dispositivo está conectado. Esto te permite ocultar la notificación permanente \"Sin dispositivos\" desactivando el canal \"Estado del dispositivo\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Mantener la notificación después de la desconexión</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Seguir mostrando los últimos niveles de batería conocidos incluso después de que tus AirPods se desconecten</string>
|
||||
<string name="settings_scanner_mode_label">Modo escáner</string>
|
||||
<string name="settings_scanner_mode_description">¿Debería el escáner de datos de Bluetooth de baja energía priorizar el rendimiento o conservar energía?</string>
|
||||
<string name="settings_autopause_label">Pausa automática</string>
|
||||
<string name="settings_autopause_description">Pausar el audio al quitar el dispositivo de tu oreja.</string>
|
||||
<string name="settings_autopplay_label">Reproducción automática</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Se requiere el siguiente permiso:</string>
|
||||
<string name="permission_system_alert_window_label">Ventana emergente</string>
|
||||
<string name="permission_system_alert_window_description">Permite que CAPod se superponga a otras aplicaciones para que la función «Mostrar ventana emergente».</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Baja energía</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Equilibrado</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Baja latencia</string>
|
||||
<string name="settings_monitor_mode_manual_label">Cuando la aplicación está abierta</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Cuando el dispositivo está conectado</string>
|
||||
<string name="settings_monitor_mode_always_label">Siempre</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Pulsación doble para terminar la llamada</string>
|
||||
<string name="device_settings_open_cd">Abrir ajustes del dispositivo</string>
|
||||
<string name="overview_card_missing_paired_device">Este perfil no tiene ningún dispositivo Bluetooth vinculado.</string>
|
||||
<string name="overview_reactions_hint_title">Las reacciones son por dispositivo</string>
|
||||
<string name="overview_reactions_hint_body">La reproducción automática, la pausa automática y las ventanas emergentes ahora se configuran por dispositivo. Toca el ícono de configuración en una tarjeta mientras tus auriculares estén conectados.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">General</string>
|
||||
<string name="device_settings_microphone_mode_label">Micrófono</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Muestra una notificación adicional cuando un dispositivo está conectado. Esto le permite ocultar la notificación permanente \"Sin dispositivos\" deshabilitando el canal \"Estado del dispositivo\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Mantener la notificación después de la desconexión</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Seguir mostrando los últimos niveles de batería conocidos incluso después de que tus AirPods se desconecten</string>
|
||||
<string name="settings_scanner_mode_label">Modo escáner</string>
|
||||
<string name="settings_scanner_mode_description">¿Debería el escáner de datos de Bluetooth de baja energía priorizar el rendimiento o conservar energía?</string>
|
||||
<string name="settings_autopause_label">Pausa automática</string>
|
||||
<string name="settings_autopause_description">Pausar el audio al quitar el dispositivo de tu oído.</string>
|
||||
<string name="settings_autopplay_label">Reproducción automática</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Se requiere el siguiente permiso:</string>
|
||||
<string name="permission_system_alert_window_label">Ventana emergente</string>
|
||||
<string name="permission_system_alert_window_description">Permite que CAPod se superponga a otras aplicaciones para que la función «Mostrar ventana emergente».</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Baja energía</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Equilibrado</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Baja latencia</string>
|
||||
<string name="settings_monitor_mode_manual_label">Cuando la aplicación está abierta</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Cuando el dispositivo está conectado</string>
|
||||
<string name="settings_monitor_mode_always_label">Siempre</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Doble pulsación para finalizar llamada</string>
|
||||
<string name="device_settings_open_cd">Abrir configuración del dispositivo</string>
|
||||
<string name="overview_card_missing_paired_device">Este perfil no tiene ningún dispositivo Bluetooth emparejado.</string>
|
||||
<string name="overview_reactions_hint_title">Las reacciones son por dispositivo</string>
|
||||
<string name="overview_reactions_hint_body">La reproducción automática, la pausa automática y las ventanas emergentes ahora se configuran por dispositivo. Toca el icono de configuración en una tarjeta mientras tus auriculares están conectados.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">General</string>
|
||||
<string name="device_settings_microphone_mode_label">Micrófono</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Kuvab seadme ühendumisel lisateavituse. See peidab pidevalt nähtava „Seadmed puuduvad“ teate, selleks keelates „Seadme olek“ kanali.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Kuva teadet ka pärast eemaldamist</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Jätkake viimase teadaoleva akutaseme kuvamist isegi pärast AirPodside eemaldamist</string>
|
||||
<string name="settings_scanner_mode_label">Skannimisrežiim</string>
|
||||
<string name="settings_scanner_mode_description">Kas Bluetooth madalama energiatarbimisega andmete skanner eelistab jõudlust või mõõdukat energiahaldust?</string>
|
||||
<string name="settings_autopause_label">Automaatne peatamine</string>
|
||||
<string name="settings_autopause_description">Seadme kõrvast eemaldamisel peata esitamine.</string>
|
||||
<string name="settings_autopplay_label">Automaatne esitamine</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Vaja on järgmist luba:</string>
|
||||
<string name="permission_system_alert_window_label">Süsteemi teate aken</string>
|
||||
<string name="permission_system_alert_window_description">Luba CAPodil toimetada teiste rakenduste kohal, et näha hüpikaknaid.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Madal võimsus</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Tasakaalustatud</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Madal latents</string>
|
||||
<string name="settings_monitor_mode_manual_label">Kui rakendus on avatud</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Kui seade on ühendatud</string>
|
||||
<string name="settings_monitor_mode_always_label">Alati</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Kahe vajutusega lõpeta kõne</string>
|
||||
<string name="device_settings_open_cd">Ava seadme seaded</string>
|
||||
<string name="overview_card_missing_paired_device">Sellel profiilil puudub seotud Bluetoothi seade.</string>
|
||||
<string name="overview_reactions_hint_title">Reaktsioonid on seadmepõhised</string>
|
||||
<string name="overview_reactions_hint_body">Automaatne esitus, automaatne paus ja hüpikaknad on nüüd seadmepõhiselt seadistatud. Puudutage kaardi seadete ikooni, kui kõrvaklapid on ühendatud.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Üldine</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Gailu bat konektatuta dagoenean jakinarazpen gehigarri bat erakusten du. Honek \"Gailurik ez\" jakinarazpen iraunkorra ezkutatzeko aukera ematen dizu \"Gailuaren egoera\" kanala desgaituz.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Mantendu jakinarazpena deskonektatu ondoren</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Jarraitu azken bateria-maila ezagunak erakusten zure AirPods-ak deskonektatu ondoren ere</string>
|
||||
<string name="settings_scanner_mode_label">Eskaner modua</string>
|
||||
<string name="settings_scanner_mode_description">Bluetooth Energia Baxuko datuen eskanerrak errendimendua lehenetsi behar al du ala energia aurreztu?</string>
|
||||
<string name="settings_autopause_label">Pausatze automatikoa</string>
|
||||
<string name="settings_autopause_description">Pausatu audioa gailua belarritik kentzean.</string>
|
||||
<string name="settings_autopplay_label">Erreprodukzio automatikoa</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Hurrengo baimena beharrezkoa da:</string>
|
||||
<string name="permission_system_alert_window_label">Sistemaren alerta-leihoa</string>
|
||||
<string name="permission_system_alert_window_description">Baimendu CAPod-i beste aplikazioen gainean marraztea \"Erakutsi pop-up-a\" ezaugarria posible egiteko.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Potentzia baxua</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Orekatua</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Latentzia baxua</string>
|
||||
<string name="settings_monitor_mode_manual_label">Aplikazioa irekita dagoenean</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Gailua konektatuta dagoenean</string>
|
||||
<string name="settings_monitor_mode_always_label">Beti</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Bikoitz sakatu deia amaitzeko</string>
|
||||
<string name="device_settings_open_cd">Ireki gailuaren ezarpenak</string>
|
||||
<string name="overview_card_missing_paired_device">Profil honek ez du Bluetooth gailurik parekaturik.</string>
|
||||
<string name="overview_reactions_hint_title">Erreakzioak gailuka konfiguratuta daude</string>
|
||||
<string name="overview_reactions_hint_body">Erreprodukzio automatikoa, geldialdia automatikoa eta laster-leihoak orain gailuka konfiguratzen dira. Sakatu txarteleko ezarpenak ikonoa aurikularrak konektatuta dituzun bitartean.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Orokorra</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofonoa</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">هنگامی که یک دستگاه متصل است، یک اعلان اضافی نشان میدهد. این به شما امکان میدهد اعلان دائمی \"بدون دستگاه\" را با غیرفعال کردن کانال \"وضعیت دستگاه\" پنهان کنید.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">حفظ اعلان پس از قطع اتصال</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">نمایش آخرین سطوح شارژ شناختهشده حتی پس از قطع اتصال AirPods شما</string>
|
||||
<string name="settings_scanner_mode_label">حالت اسکنر</string>
|
||||
<string name="settings_scanner_mode_description">آیا اسکنر داده بلوتوث کممصرف باید عملکرد را در اولویت قرار دهد یا انرژی را ذخیره کند؟</string>
|
||||
<string name="settings_autopause_label">مکث خودکار</string>
|
||||
<string name="settings_autopause_description">هنگام برداشتن دستگاه از گوش، صدا را متوقف کنید.</string>
|
||||
<string name="settings_autopplay_label">پخش خودکار</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">مجوز زیر مورد نیاز است:</string>
|
||||
<string name="permission_system_alert_window_label">پنجره هشدار سیستم</string>
|
||||
<string name="permission_system_alert_window_description">به CAPod اجازه دهید روی سایر برنامهها نمایش دهد تا ویژگی \"نمایش پاپآپ\" ممکن شود.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">کممصرف</string>
|
||||
<string name="settings_scanner_mode_balanced_label">متعادل</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">تأخیر کم</string>
|
||||
<string name="settings_monitor_mode_manual_label">وقتی برنامه باز است</string>
|
||||
<string name="settings_monitor_mode_automatic_label">وقتی دستگاه متصل است</string>
|
||||
<string name="settings_monitor_mode_always_label">همیشه</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">دوبار فشار برای پایان دادن تماس</string>
|
||||
<string name="device_settings_open_cd">باز کردن تنظیمات دستگاه</string>
|
||||
<string name="overview_card_missing_paired_device">این پروفایل هیچ دستگاه بلوتوثی جفتشدهای ندارد.</string>
|
||||
<string name="overview_reactions_hint_title">واکنشها برای هر دستگاه جداگانه هستند</string>
|
||||
<string name="overview_reactions_hint_body">پخش خودکار، توقف خودکار و پنجرههای بازشو اکنون برای هر دستگاه پیکربندی میشوند. در حالی که هدفونهای شما متصل هستند، روی آیکون تنظیمات روی یک کارت ضربه بزنید.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">عمومی</string>
|
||||
<string name="device_settings_microphone_mode_label">میکروفون</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Näyttää lisäilmoituksen, kun laite on yhdistetty. Tämä antaa sinun piilottaa pysyvän \"Ei laitteita\" -ilmoituksen poistamalla \"Laitteen tila\" -kanavan käytöstä.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Säilytä ilmoitus yhteyden katkaisun jälkeen</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Jatka viimeisten tunnettujen akkutasojen näyttämistä senkin jälkeen, kun AirPodit katkaisevat yhteyden</string>
|
||||
<string name="settings_scanner_mode_label">Skanneritila</string>
|
||||
<string name="settings_scanner_mode_description">Pitäisikö Bluetooth Low Energy -dataskannerin priorisoida suorituskykyä vai säästää energiaa?</string>
|
||||
<string name="settings_autopause_label">Automaattinen tauko</string>
|
||||
<string name="settings_autopause_description">Keskeytä ääni, kun poistat laitteen korvastasi.</string>
|
||||
<string name="settings_autopplay_label">Automaattinen toisto</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Seuraava lupa vaaditaan:</string>
|
||||
<string name="permission_system_alert_window_label">Järjestelmän hälytysikkuna</string>
|
||||
<string name="permission_system_alert_window_description">Salli CAPod-sovelluksen piirtää muiden sovellusten päälle mahdollistaakseen \"Näytä ponnahdusikkuna\" -ominaisuuden.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Virransäästö</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Tasapainoinen</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Matala viive</string>
|
||||
<string name="settings_monitor_mode_manual_label">Kun sovellus on auki</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Kun laite on yhdistetty</string>
|
||||
<string name="settings_monitor_mode_always_label">Aina</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Kaksoispainallus lopettaa puhelun</string>
|
||||
<string name="device_settings_open_cd">Avaa laiteasetukset</string>
|
||||
<string name="overview_card_missing_paired_device">Tällä profiililla ei ole yhdistettyä Bluetooth-laitetta.</string>
|
||||
<string name="overview_reactions_hint_title">Reaktiot ovat laitekohtaisia</string>
|
||||
<string name="overview_reactions_hint_body">Automaattinen toisto, automaattinen tauko ja ponnahdusikkunat on nyt määritetty laitekohtaisesti. Napauta kortissa olevaa asetuskuvaketta, kun kuulokkeet ovat yhdistettynä.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Yleiset</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofoni</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Nagpapakita ng karagdagang notipikasyon kapag konektado ang isang device. Pinapayagan ka nitong itago ang permanenteng \"Walang mga device\" na notipikasyon sa pamamagitan ng pag-disable sa \"Katayuan ng device\" na channel.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Panatilihin ang notipikasyon pagkatapos mag-disconnect</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Ipagpatuloy ang pagpapakita ng huling kilalang mga antas ng baterya kahit pagkatapos mag-disconnect ang iyong AirPods</string>
|
||||
<string name="settings_scanner_mode_label">Mode ng scanner</string>
|
||||
<string name="settings_scanner_mode_description">Dapat bang unahin ng Bluetooth Low Energy data scanner ang performance o magtipid ng enerhiya?</string>
|
||||
<string name="settings_autopause_label">Auto pause</string>
|
||||
<string name="settings_autopause_description">I-pause ang audio kapag inaalis ang device sa iyong tainga.</string>
|
||||
<string name="settings_autopplay_label">Auto play</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">Kailangan ang sumusunod na pahintulot:</string>
|
||||
<string name="permission_system_alert_window_label">System Alert Window</string>
|
||||
<string name="permission_system_alert_window_description">Payagan ang CAPod na mag-draw sa ibabaw ng ibang mga app para maging posible ang feature na \"Ipakita ang PopUp\".</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Mababang enerhiya</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Balanse</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Mababang latency</string>
|
||||
<string name="settings_monitor_mode_manual_label">Kapag bukas ang app</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Kapag konektado ang device</string>
|
||||
<string name="settings_monitor_mode_always_label">Palagi</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dalawang pindutin para tapusin ang tawag</string>
|
||||
<string name="device_settings_open_cd">Buksan ang mga setting ng device</string>
|
||||
<string name="overview_card_missing_paired_device">Ang profile na ito ay walang naipares na Bluetooth device.</string>
|
||||
<string name="overview_reactions_hint_title">Ang mga reaksyon ay bawat device</string>
|
||||
<string name="overview_reactions_hint_body">Ang auto-play, auto-pause at mga pop-up ay naka-configure na ngayon bawat device. I-tap ang icon ng mga setting sa isang card habang nakakonekta ang iyong mga headphone.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Pangkalahatan</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikropono</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Affiche une notification supplémentaire si un appareil est connecté. Cela vous permet de masquer la notification permanente « Aucun appareil » en désactivant le canal « État de l’appareil ».</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Garder la notification après déconnexion</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Afficher les derniers niveaux de batterie connus même après déconnexion de vos AirPods</string>
|
||||
<string name="settings_scanner_mode_label">Mode analyse</string>
|
||||
<string name="settings_scanner_mode_description">L’analyseur des données « basse énergie » de Bluetooth devrait-il prioriser les performances ou préserver l’énergie ?</string>
|
||||
<string name="settings_autopause_label">Pause automatique</string>
|
||||
<string name="settings_autopause_description">Mettre le son en pause si vous ôtez l’appareil de votre oreille.</string>
|
||||
<string name="settings_autopplay_label">Lecture automatique</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">L’autorisation suivante est nécessaire :</string>
|
||||
<string name="permission_system_alert_window_label">Fenêtre d’alerte système</string>
|
||||
<string name="permission_system_alert_window_description">Permettre à CAPod d’apparaître par-dessus les autres applis pour rendre la fonction « Afficher une fenêtre contextuelle » possible.</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Puissance faible</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Équilibré</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Latence faible</string>
|
||||
<string name="settings_monitor_mode_manual_label">Si l’appli est ouverte</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Si l’appareil est connecté</string>
|
||||
<string name="settings_monitor_mode_always_label">Toujours</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Double appui pour terminer l’appel</string>
|
||||
<string name="device_settings_open_cd">Ouvrir les paramètres de l’appareil</string>
|
||||
<string name="overview_card_missing_paired_device">Ce profil n\'a aucun appareil Bluetooth associé.</string>
|
||||
<string name="overview_reactions_hint_title">Les réactions sont par appareil</string>
|
||||
<string name="overview_reactions_hint_body">La lecture automatique, la pause automatique et les fenêtres contextuelles sont désormais configurées par appareil. Appuyez sur l’icône des paramètres d’une carte lorsque vos écouteurs sont connectés.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Général</string>
|
||||
<string name="device_settings_microphone_mode_label">Microphone</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Mostra unha notificación adicional cando un dispositivo está conectado. Isto permíteche ocultar a notificación permanente de \"Sen dispositivos\" desactivando a canle \"Estado do dispositivo\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Manter notificación tras desconexión</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Seguir mostrando os últimos niveis de batería coñecidos mesmo despois de que os teus AirPods se desconecten</string>
|
||||
<string name="settings_scanner_mode_label">Modo de escáner</string>
|
||||
<string name="settings_scanner_mode_description">O escáner de datos Bluetooth de baixa enerxía debería priorizar o rendemento ou conservar enerxía?</string>
|
||||
<string name="settings_autopause_label">Pausa automática</string>
|
||||
<string name="settings_autopause_description">Pausar o son ao quitar o dispositivo da orella.</string>
|
||||
<string name="settings_autopplay_label">Reprodución automática</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">É necesario o seguinte permiso:</string>
|
||||
<string name="permission_system_alert_window_label">Xanela de alerta do sistema</string>
|
||||
<string name="permission_system_alert_window_description">Permitir que CAPod debuxe sobre outras aplicacións para facer posible a función \"Mostrar PopUp\".</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Baixo consumo</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Equilibrado</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Baixa latencia</string>
|
||||
<string name="settings_monitor_mode_manual_label">Cando a aplicación está aberta</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Cando o dispositivo está conectado</string>
|
||||
<string name="settings_monitor_mode_always_label">Sempre</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Prema dúas veces para finalizar a chamada</string>
|
||||
<string name="device_settings_open_cd">Abrir a configuración do dispositivo</string>
|
||||
<string name="overview_card_missing_paired_device">Este perfil non ten ningún dispositivo Bluetooth emparellado.</string>
|
||||
<string name="overview_reactions_hint_title">As reaccións son por dispositivo</string>
|
||||
<string name="overview_reactions_hint_body">A reprodución automática, a pausa automática e as ventás emerxentes agora configúranse por dispositivo. Toca a icona de axustes nunha tarxeta mentres os teus auriculares estean conectados.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Xeral</string>
|
||||
<string name="device_settings_microphone_mode_label">Micrófono</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">जब कोई डिवाइस कनेक्ट होता है तो एक अतिरिक्त सूचना दिखाता है। यह आपको \"डिवाइस स्थिति\" चैनल को अक्षम करके स्थायी \"कोई डिवाइस नहीं\" सूचना को छिपाने की अनुमति देता है।</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">डिस्कनेक्ट होने के बाद सूचना बनाए रखें</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">आपके AirPods डिस्कनेक्ट होने के बाद भी अंतिम ज्ञात बैटरी स्तर दिखाना जारी रखें</string>
|
||||
<string name="settings_scanner_mode_label">स्कैनर मोड</string>
|
||||
<string name="settings_scanner_mode_description">क्या ब्लूटूथ लो एनर्जी डेटा स्कैनर को प्रदर्शन को प्राथमिकता देनी चाहिए या ऊर्जा बचानी चाहिए?</string>
|
||||
<string name="settings_autopause_label">ऑटो-पॉज़</string>
|
||||
<string name="settings_autopause_description">डिवाइस को अपने कान से हटाने पर ऑडियो रोकें।</string>
|
||||
<string name="settings_autopplay_label">ऑटो-प्ले</string>
|
||||
@@ -222,9 +220,6 @@
|
||||
<string name="permission_required_title">निम्नलिखित अनुमति आवश्यक है:</string>
|
||||
<string name="permission_system_alert_window_label">सिस्टम अलर्ट विंडो</string>
|
||||
<string name="permission_system_alert_window_description">CAPod को अन्य ऐप्स के ऊपर ड्रॉ करने की अनुमति दें ताकि \"पॉपअप दिखाएं\" सुविधा संभव हो सके।</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">कम बिजली</string>
|
||||
<string name="settings_scanner_mode_balanced_label">संतुलित</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">कम विलंब</string>
|
||||
<string name="settings_monitor_mode_manual_label">जब ऐप खुला हो</string>
|
||||
<string name="settings_monitor_mode_automatic_label">जब डिवाइस कनेक्ट हो</string>
|
||||
<string name="settings_monitor_mode_always_label">हमेशा</string>
|
||||
@@ -470,6 +465,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">कॉल समाप्त करने के लिए दो बार दबाएं</string>
|
||||
<string name="device_settings_open_cd">डिवाइस सेटिंग्स खोलें</string>
|
||||
<string name="overview_card_missing_paired_device">इस प्रोफ़ाइल में कोई पेयर किया गया Bluetooth डिवाइस नहीं है।</string>
|
||||
<string name="overview_reactions_hint_title">रिएक्शन प्रति डिवाइस हैं</string>
|
||||
<string name="overview_reactions_hint_body">ऑटो-प्ले, ऑटो-पॉज़ और पॉप-अप अब प्रति डिवाइस कॉन्फ़िगर किए गए हैं। जब आपके हेडफ़ोन कनेक्ट हों, तो कार्ड पर सेटिंग आइकन टैप करें।</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">सामान्य</string>
|
||||
<string name="device_settings_microphone_mode_label">माइक्रोफ़ोन</string>
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
<string name="settings_monitor_connected_notification_description">Prikazuje dodatnu obavijest kada je uređaj povezan. To vam omogućuje da sakrijete trajnu obavijest \"Nema uređaja\" onemogućavanjem kanala \"Status uređaja\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Zadrži obavijest nakon prekida veze</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Nastavi prikazivati zadnje poznate razine baterije i nakon što se vaši AirPods odspoje</string>
|
||||
<string name="settings_scanner_mode_label">Način skeniranja</string>
|
||||
<string name="settings_scanner_mode_description">Treba li Bluetooth Low Energy skener podataka dati prednost performansama ili štedjeti energiju?</string>
|
||||
<string name="settings_autopause_label">Automatska pauza</string>
|
||||
<string name="settings_autopause_description">Pauzirajte zvuk kada uklonite uređaj iz uha.</string>
|
||||
<string name="settings_autopplay_label">Automatska reprodukcija</string>
|
||||
@@ -224,9 +222,6 @@
|
||||
<string name="permission_required_title">Sljedeća dozvola je potrebna:</string>
|
||||
<string name="permission_system_alert_window_label">Prozor sistemskog upozorenja</string>
|
||||
<string name="permission_system_alert_window_description">Dopustite CAPod-u crtanje preko drugih aplikacija kako bi omogućili značajku \"Prikaži skočni prozor\".</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Niska potrošnja</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Uravnoteženo</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Niska latencija</string>
|
||||
<string name="settings_monitor_mode_manual_label">Kada je aplikacija otvorena</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Kada je uređaj povezan</string>
|
||||
<string name="settings_monitor_mode_always_label">Uvijek</string>
|
||||
@@ -475,6 +470,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dvostruki pritisak za završetak poziva</string>
|
||||
<string name="device_settings_open_cd">Otvori postavke uređaja</string>
|
||||
<string name="overview_card_missing_paired_device">Ovaj profil nema uparenog Bluetooth uređaja.</string>
|
||||
<string name="overview_reactions_hint_title">Reakcije su po uređaju</string>
|
||||
<string name="overview_reactions_hint_body">Automatska reprodukcija, automatska pauza i skočni prozori sada se konfiguriraju po uređaju. Dodirnite ikonu postavki na kartici dok su vaše slušalice spojene.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Općenito</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user