mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
251
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da5af8e4b1 | ||
|
|
6c848eaa55 | ||
|
|
15054d836d | ||
|
|
0ac9bba482 | ||
|
|
247bc13e0a | ||
|
|
33126be817 | ||
|
|
b02b31043b | ||
|
|
6786b5aa8a | ||
|
|
8577e7bae8 | ||
|
|
df88399e7a | ||
|
|
16392d5721 | ||
|
|
2edf5411ce | ||
|
|
89e46d3858 | ||
|
|
da45eb5dda | ||
|
|
a1868f495d | ||
|
|
ceb5034c3b | ||
|
|
02ff7089c0 | ||
|
|
2fa33b13a3 | ||
|
|
96a654638a | ||
|
|
b4aa7b0815 | ||
|
|
df5389c27e | ||
|
|
0611ec0e08 | ||
|
|
c325112b44 | ||
|
|
65f9e1d9db | ||
|
|
c97c8f4852 | ||
|
|
6b2073eff2 | ||
|
|
2b08d9a4d0 | ||
|
|
977f135c50 | ||
|
|
3c777d377e | ||
|
|
b98994f2f1 | ||
|
|
85860a53bf | ||
|
|
7a967ce648 | ||
|
|
4c7eac870d | ||
|
|
ff58e01551 | ||
|
|
004ea6cbcb | ||
|
|
a42b88aa78 | ||
|
|
43088cf8b8 | ||
|
|
9460584891 | ||
|
|
570118ca94 | ||
|
|
d9f0de2e05 | ||
|
|
3d5dcfb21d | ||
|
|
e0dc9ea737 | ||
|
|
b4d0904f55 | ||
|
|
2caee59713 | ||
|
|
6712ba78ba | ||
|
|
cba30aee47 | ||
|
|
c6cd8d68f7 | ||
|
|
d2fbf9d79f | ||
|
|
738a35dbe9 | ||
|
|
1278ec55c2 | ||
|
|
95ff34b373 | ||
|
|
1329429130 | ||
|
|
8c3c09dc03 | ||
|
|
10bf65bd09 | ||
|
|
b57b3458ab | ||
|
|
6aeee4816c | ||
|
|
3c8f24fa50 | ||
|
|
5e60629b4c | ||
|
|
21d740ae51 | ||
|
|
f4e8f08f4f | ||
|
|
7bf472023f | ||
|
|
01fc9654f8 | ||
|
|
c1b247c219 | ||
|
|
101fb68205 | ||
|
|
cda16ca06a | ||
|
|
e9b29a2a43 | ||
|
|
5df809a492 | ||
|
|
ffe8a58746 | ||
|
|
eab0296125 | ||
|
|
2ff143b858 | ||
|
|
3b2dcc1a5c | ||
|
|
d59dc8c225 | ||
|
|
877c5d9013 | ||
|
|
11a19da0b8 | ||
|
|
eda81822aa | ||
|
|
3b00613dde | ||
|
|
a434a0bee0 | ||
|
|
33ca43eaf1 | ||
|
|
df4ec3501b | ||
|
|
fc54e540e8 | ||
|
|
e8481cc8cd | ||
|
|
eb8a9b3b46 | ||
|
|
79ec3ba932 | ||
|
|
8f910b6dcc | ||
|
|
92fa181925 | ||
|
|
ca82f96b30 | ||
|
|
2ea2bb1765 | ||
|
|
fb22798469 | ||
|
|
d48f475a34 | ||
|
|
c88c57e856 | ||
|
|
17892fbd24 | ||
|
|
2352974d4d | ||
|
|
e216fbf66e | ||
|
|
f3a947a8a3 | ||
|
|
00264c1d2b | ||
|
|
54a02837fd | ||
|
|
7a5af76ebb | ||
|
|
92bd754623 | ||
|
|
85b9978e05 | ||
|
|
1ebb2319d4 | ||
|
|
f956625d2e | ||
|
|
9c2ebd9d04 | ||
|
|
fb0527b0bf | ||
|
|
6b922e8845 | ||
|
|
663e2d3b08 | ||
|
|
858b86af6a | ||
|
|
647300fd3a | ||
|
|
3eaf99390e | ||
|
|
0ee7915e31 | ||
|
|
10c880a800 | ||
|
|
b1bf658ff3 | ||
|
|
77dc13d9ba | ||
|
|
21b6cfc013 | ||
|
|
5d953ee23e | ||
|
|
42f2f5bfee | ||
|
|
6708901394 | ||
|
|
788038beb7 | ||
|
|
18ac05ed80 | ||
|
|
02f68821d2 | ||
|
|
62886f00f1 | ||
|
|
f2eb45d00b | ||
|
|
2fca9097c2 | ||
|
|
051677e7dd | ||
|
|
fbcd595b45 | ||
|
|
aa38f58f62 | ||
|
|
a3b01a02ee | ||
|
|
8544983984 | ||
|
|
07a11799b1 | ||
|
|
65a7b1933a | ||
|
|
bbfb735124 | ||
|
|
7b474fb0e1 | ||
|
|
c6524d7cee | ||
|
|
148cdf5109 | ||
|
|
60a3039415 | ||
|
|
6f7b6e30c9 | ||
|
|
08c095eae4 | ||
|
|
9087be6282 | ||
|
|
d46fe35eab | ||
|
|
1afb77ee33 | ||
|
|
a9215fc11c | ||
|
|
d80a110280 | ||
|
|
fdfc7f44fb | ||
|
|
26fbfc08f7 | ||
|
|
89049ee3ab | ||
|
|
68f81f900b | ||
|
|
513bd891af | ||
|
|
37cd753ab8 | ||
|
|
34bf2311b4 | ||
|
|
a86093f617 | ||
|
|
71f66e6004 | ||
|
|
2d1ab85e01 | ||
|
|
f029eb9102 | ||
|
|
471837ab88 | ||
|
|
1e80d742cc | ||
|
|
16b6273938 | ||
|
|
3407370563 | ||
|
|
3cda73a36c | ||
|
|
b604c423ad | ||
|
|
0c8b121c2c | ||
|
|
da11961611 | ||
|
|
48b9952fc0 | ||
|
|
99fa99eba5 | ||
|
|
06e67e04ac | ||
|
|
4a2f7c20a2 | ||
|
|
82e8769407 | ||
|
|
ad38702df1 | ||
|
|
3f98c62a8f | ||
|
|
ec7447992f | ||
|
|
99958e13eb | ||
|
|
b9fe5d4a14 | ||
|
|
1443fd5714 | ||
|
|
798083b235 | ||
|
|
18a7311089 | ||
|
|
9f49d62f12 | ||
|
|
0c3c4e7ea1 | ||
|
|
7cdf653842 | ||
|
|
32d99eada8 | ||
|
|
9e4adb16c6 | ||
|
|
4b5638e4f0 | ||
|
|
d8fb9cdd30 | ||
|
|
3920489dcd | ||
|
|
09d013588d | ||
|
|
d8bd03c65d | ||
|
|
8b2c6b9498 | ||
|
|
0f63d3a0d4 | ||
|
|
672eb333b0 | ||
|
|
de609b4d05 | ||
|
|
7e6e9eee36 | ||
|
|
a321bb733c | ||
|
|
490369a3e1 | ||
|
|
a2cce044ef | ||
|
|
8884bb88c7 | ||
|
|
313b0e9131 | ||
|
|
47048cf7e7 | ||
|
|
ef1c4615aa | ||
|
|
c9587b2bf0 | ||
|
|
669fa2fd22 | ||
|
|
fb8ec72825 | ||
|
|
d1687f574f | ||
|
|
72f47bec1f | ||
|
|
20e8aecf83 | ||
|
|
ee41474670 | ||
|
|
ac29e28eda | ||
|
|
58b96cbdee | ||
|
|
0c1fc15251 | ||
|
|
7b8e0cc952 | ||
|
|
c8aa591feb | ||
|
|
afa4d6e807 | ||
|
|
e6b7eda9e7 | ||
|
|
bbc4e05c8f | ||
|
|
50bccbaa2e | ||
|
|
d71e83a2af | ||
|
|
92a67702b7 | ||
|
|
2f9185efa7 | ||
|
|
a10216520b | ||
|
|
d5354e5156 | ||
|
|
a8f9b1f732 | ||
|
|
08a796e621 | ||
|
|
15f3a68f8e | ||
|
|
e0bc6dafa0 | ||
|
|
430c1236d7 | ||
|
|
3ac357161a | ||
|
|
3c5f1bf21f | ||
|
|
5544cd3770 | ||
|
|
71a060feb9 | ||
|
|
25d643257c | ||
|
|
85900e5bff | ||
|
|
d3257d6c5e | ||
|
|
afe1399f72 | ||
|
|
20ce2d3e22 | ||
|
|
cea8f2290d | ||
|
|
1b5b59c41b | ||
|
|
8df7c19e84 | ||
|
|
88b0069b94 | ||
|
|
3b8ddc9f06 | ||
|
|
708c7f38ec | ||
|
|
139daa1cc2 | ||
|
|
b85ac778c0 | ||
|
|
a3e2c9e61d | ||
|
|
ad78a0570c | ||
|
|
7e6ee274d8 | ||
|
|
daf7411c36 | ||
|
|
d3dedbe6cd | ||
|
|
d5411586d5 | ||
|
|
94e2d40554 | ||
|
|
db80fa1b46 | ||
|
|
9b569b8d07 | ||
|
|
90288880fb | ||
|
|
870e518ff6 | ||
|
|
6d89169bae | ||
|
|
ac77ee2703 |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__ide__getDiagnostics",
|
||||
"Bash(./gradlew tasks:*)",
|
||||
"Bash(./gradlew:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(rg:*)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:support.google.com)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:mvnrepository.com)",
|
||||
"WebFetch(domain:kotlinlang.org)",
|
||||
"WebFetch(domain:developer.android.com)",
|
||||
"WebFetch(domain:issuetracker.google.com)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
name: 'Common project setup'
|
||||
description: 'Setup the base project'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
shell: bash
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Cache Gradle Wrapper
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
!~/.gradle/wrapper/dists/**/gradle*.zip
|
||||
key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-wrapper-
|
||||
|
||||
- name: Cache Gradle Dependencies
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-caches-
|
||||
|
||||
- name: Cache Android Global Build-Cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||
with:
|
||||
path: |
|
||||
~/.android/build-cache
|
||||
key: ${{ runner.os }}-android-build-cache-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-android-build-cache-
|
||||
@@ -0,0 +1,14 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- changelog-ignore
|
||||
categories:
|
||||
- title: ":rocket: Enhancements"
|
||||
labels:
|
||||
- enhancement
|
||||
- title: ":lady_beetle: Bug fixes"
|
||||
labels:
|
||||
- bug
|
||||
- title: ":shrug: Other changes"
|
||||
labels:
|
||||
- "*"
|
||||
@@ -7,46 +7,55 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build-all:
|
||||
name: Assemble all variants
|
||||
lint-vital:
|
||||
name: Lint vitals
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
flavor: [ Foss,Gplay ]
|
||||
variant: [ Beta,Release ]
|
||||
module: [ app ]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
- name: Check for fatal lint issues
|
||||
run: ./gradlew lintVital${{ matrix.flavor }}${{ matrix.variant }}
|
||||
|
||||
build-modules:
|
||||
name: Build apps
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
flavor: [ Foss,Gplay ]
|
||||
variant: [ Debug ]
|
||||
runs-on: ubuntu-latest
|
||||
module: [ app ]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: '11'
|
||||
distribution: 'adopt'
|
||||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
- name: Build (Flavor ${{ matrix.flavor }}, Variant ${{ matrix.variant }})
|
||||
run: ./gradlew assemble${{ matrix.flavor }}${{ matrix.variant }}
|
||||
- name: Build modules
|
||||
run: ./gradlew ${{ matrix.module }}:assemble${{ matrix.flavor }}${{ matrix.variant }}
|
||||
|
||||
test-all:
|
||||
name: Run all tests
|
||||
test-modules:
|
||||
name: Unit tests
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
flavor: [ Foss,Gplay ]
|
||||
variant: [ Release ]
|
||||
runs-on: ubuntu-latest
|
||||
variant: [ Debug,Beta,Release ]
|
||||
flavor: [ testFoss,testGplay ]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: '11'
|
||||
distribution: 'adopt'
|
||||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
- name: Run tests (Flavor ${{ matrix.flavor }}, Variant ${{ matrix.variant }})
|
||||
run: ./gradlew test${{ matrix.flavor }}${{ matrix.variant }}
|
||||
- name: Test modules
|
||||
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
jobs:
|
||||
validation:
|
||||
name: "Validation"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: gradle/wrapper-validation-action@v1
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 #v4.3.1
|
||||
|
||||
@@ -8,7 +8,9 @@ on:
|
||||
jobs:
|
||||
release-github:
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-22.04
|
||||
environment: foss-production
|
||||
steps:
|
||||
- name: Decode Keystore
|
||||
@@ -21,25 +23,22 @@ jobs:
|
||||
echo $ENCODED_KEYSTORE | base64 -di > "${TMP_KEYSTORE_FILE_PATH}"
|
||||
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
- name: Get the version
|
||||
id: tagger
|
||||
uses: jimschubert/query-tag-action@v2
|
||||
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
|
||||
with:
|
||||
skip-unshallow: 'true'
|
||||
abbrev: false
|
||||
commit-ish: HEAD
|
||||
|
||||
- name: Install JDK ${{ matrix.java_version }}
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: 11
|
||||
|
||||
- name: Assemble beta APK
|
||||
if: contains(steps.tagger.outputs.tag, '-beta')
|
||||
run: ./gradlew assembleFossBeta
|
||||
@@ -48,7 +47,6 @@ jobs:
|
||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }}
|
||||
|
||||
- name: Assemble production APK
|
||||
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||
@@ -58,11 +56,10 @@ jobs:
|
||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }}
|
||||
|
||||
- name: Create pre-release
|
||||
if: contains(steps.tagger.outputs.tag, '-beta')
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ steps.tagger.outputs.tag }}
|
||||
@@ -76,21 +73,19 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ steps.tagger.outputs.tag }}
|
||||
name: ${{ steps.tagger.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
app/build/outputs/apk/foss/release/*.apk
|
||||
app-wear/build/outputs/apk/foss/release/*.apk
|
||||
files: app/build/outputs/apk/foss/release/*.apk
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
release-gplay:
|
||||
name: Create Google Play release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
environment: gplay-production
|
||||
steps:
|
||||
- name: Decode Keystore
|
||||
@@ -113,31 +108,36 @@ jobs:
|
||||
echo $ENCODED_SERVICE_KEY | base64 -di > "${TMP_SERVICEKEY_FILE_PATH}"
|
||||
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
- name: Get the version
|
||||
id: tagger
|
||||
uses: jimschubert/query-tag-action@v2
|
||||
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
|
||||
with:
|
||||
skip-unshallow: 'true'
|
||||
abbrev: false
|
||||
commit-ish: HEAD
|
||||
|
||||
- name: Install JDK ${{ matrix.java_version }}
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: 11
|
||||
|
||||
- name: Set up ruby env
|
||||
uses: ruby/setup-ruby@v1
|
||||
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 #v1.229.0
|
||||
with:
|
||||
ruby-version: 2.7.6
|
||||
ruby-version: 3.3.6
|
||||
bundler-cache: true
|
||||
|
||||
# - name: Assemble WearOS beta and upload to Google Play
|
||||
# if: contains(steps.tagger.outputs.tag, '-beta')
|
||||
# run: bundle exec fastlane beta_wearos
|
||||
# env:
|
||||
# STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||
# KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
# KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
|
||||
- name: Assemble beta and upload to Google Play
|
||||
if: contains(steps.tagger.outputs.tag, '-beta')
|
||||
run: bundle exec fastlane beta
|
||||
@@ -145,7 +145,14 @@ jobs:
|
||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }}
|
||||
|
||||
# - name: Assemble WearOS production and upload to Google Play
|
||||
# if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||
# run: bundle exec fastlane production_wearos
|
||||
# env:
|
||||
# STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||
# KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
# KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
|
||||
- name: Assemble production and upload to Google Play
|
||||
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||
@@ -153,5 +160,4 @@ jobs:
|
||||
env:
|
||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
@@ -12,3 +12,5 @@
|
||||
*.jks
|
||||
.local/*
|
||||
/fastlane/report.xml
|
||||
/fastlane/Appfile
|
||||
/fastlane/README.md
|
||||
Generated
+1
@@ -9,6 +9,7 @@
|
||||
<option name="DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION" value="true" />
|
||||
<option name="IMPORT_LAYOUT_TABLE">
|
||||
<value>
|
||||
<package name="" withSubpackages="true" static="false" module="true" />
|
||||
<package name="android" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="com" withSubpackages="true" static="false" />
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
layout: plain
|
||||
permalink: /changelog
|
||||
title: "Changelog"
|
||||
---
|
||||
|
||||
# Changelog for CAPod
|
||||
|
||||
{% for release in site.github.releases %}
|
||||
|
||||
## {{ release.tag_name }} - {{ release.published_at | date: "%B %d, %Y" }}
|
||||
|
||||
{% assign clean_body = release.body | strip %}
|
||||
{% assign no_comments = clean_body | replace: "<!-- Release notes generated using configuration in .github/release.yml", "" %}
|
||||
{% assign no_comments = no_comments | split: "-->" %}
|
||||
{% if no_comments.size > 1 %}
|
||||
{% assign clean_content = no_comments[1] | strip %}
|
||||
{% else %}
|
||||
{% assign clean_content = no_comments[0] | strip %}
|
||||
{% endif %}
|
||||
|
||||
{% comment %} Make links clickable {% endcomment %}
|
||||
{% assign lines = clean_content | split: "
|
||||
" %}
|
||||
{% assign processed_lines = "" %}
|
||||
{% for line in lines %}
|
||||
{% if line contains "**Full Changelog**:" %}
|
||||
{% comment %} Handle Full Changelog links {% endcomment %}
|
||||
{% assign parts = line | split: ": " %}
|
||||
{% if parts.size > 1 %}
|
||||
{% assign url = parts[1] | strip %}
|
||||
{% assign clickable_line = "**[View Changes](" | append: url | append: ")**" %}
|
||||
{% assign processed_lines = processed_lines | append: clickable_line | append: "
|
||||
" %}
|
||||
{% else %}
|
||||
{% assign processed_lines = processed_lines | append: line | append: "
|
||||
" %}
|
||||
{% endif %}
|
||||
{% elsif line contains " in https://github.com/" and line contains "/pull/" %}
|
||||
{% comment %} Handle pull request links {% endcomment %}
|
||||
{% assign pr_parts = line | split: " in https://github.com/" %}
|
||||
{% if pr_parts.size > 1 %}
|
||||
{% assign before_url = pr_parts[0] %}
|
||||
{% assign after_url = pr_parts[1] %}
|
||||
{% assign url = "https://github.com/" | append: after_url %}
|
||||
{% assign pr_number = after_url | split: "/pull/" %}
|
||||
{% if pr_number.size > 1 %}
|
||||
{% assign pr_num = pr_number[1] | split: " " | first %}
|
||||
{% assign clickable_line = before_url | append: " in [#" | append: pr_num | append: "](" | append: url | append: ")" %}
|
||||
{% assign processed_lines = processed_lines | append: clickable_line | append: "
|
||||
" %}
|
||||
{% else %}
|
||||
{% assign processed_lines = processed_lines | append: line | append: "
|
||||
" %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% assign processed_lines = processed_lines | append: line | append: "
|
||||
" %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% assign processed_lines = processed_lines | append: line | append: "
|
||||
" %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% comment %} Add proper spacing between sections and bullet points {% endcomment %}
|
||||
{% assign final_content = processed_lines | replace: "
|
||||
### ", "
|
||||
|
||||
### " %}
|
||||
{% assign final_content = final_content | replace: "
|
||||
## ", "
|
||||
|
||||
## " %}
|
||||
{% assign final_content = final_content | replace: "
|
||||
- ", "
|
||||
|
||||
- " %}
|
||||
|
||||
{% comment %} Check if there are any bullet points (actual release notes) {% endcomment %}
|
||||
{% if final_content contains "## " or final_content contains "- " %}
|
||||
{{ final_content | markdownify }}
|
||||
{% else %}
|
||||
*No release notes available.*
|
||||
|
||||
{{ final_content | markdownify }}
|
||||
{% endif %}
|
||||
|
||||
---
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,144 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
# Build debug version
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Build all variants (FOSS and Google Play flavors)
|
||||
./gradlew assemble
|
||||
|
||||
# Build specific flavor and type
|
||||
./gradlew assembleFossDebug
|
||||
./gradlew assembleGplayRelease
|
||||
|
||||
# Build app bundles for Play Store
|
||||
./gradlew bundleGplayRelease
|
||||
```
|
||||
|
||||
### Testing Commands
|
||||
|
||||
```bash
|
||||
# Run all unit tests
|
||||
./gradlew test
|
||||
|
||||
# Run unit tests for specific variant
|
||||
./gradlew testFossDebugUnitTest
|
||||
|
||||
# Run instrumentation tests (requires connected device/emulator)
|
||||
./gradlew connectedAndroidTest
|
||||
./gradlew connectedFossDebugAndroidTest
|
||||
|
||||
# Run all checks (lint + tests)
|
||||
./gradlew check
|
||||
```
|
||||
|
||||
### Code Quality Commands
|
||||
|
||||
```bash
|
||||
# Run lint for all variants
|
||||
./gradlew lint
|
||||
|
||||
# Run lint for specific variant
|
||||
./gradlew lintFossDebug
|
||||
|
||||
# Auto-fix lint issues where possible
|
||||
./gradlew lintFix
|
||||
|
||||
# Update lint baseline
|
||||
./gradlew updateLintBaseline
|
||||
```
|
||||
|
||||
### Release Commands
|
||||
|
||||
```bash
|
||||
./gradlew assembleFossRelease assembleGplayRelease
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Multi-Module Structure
|
||||
|
||||
- **app/**: Main Android application with FOSS and Google Play flavors
|
||||
- **app-common/**: Shared code between main app and Wear OS
|
||||
- **app-wear/**: Dedicated Wear OS companion application
|
||||
|
||||
### Core Architecture Patterns
|
||||
|
||||
- **MVVM**: ViewModels with LiveData/StateFlow for UI state management
|
||||
- **Dependency Injection**: Hilt/Dagger for dependency management
|
||||
- **Coroutines**: Extensive use of Kotlin coroutines for async operations
|
||||
- **Repository Pattern**: Data layer abstraction for monitoring and settings
|
||||
|
||||
### Key Components
|
||||
|
||||
#### PodMonitor System
|
||||
|
||||
- `PodMonitor`: Core service that detects and tracks AirPods via Bluetooth LE
|
||||
- `MonitorControl`: Manages background monitoring worker lifecycle
|
||||
- `MonitorWorker`: Background worker that continuously scans for AirPods
|
||||
- `BluetoothEventReceiver`: Handles system Bluetooth events
|
||||
|
||||
#### Reaction System
|
||||
|
||||
- `ReactionSettingsFragment`: Configuration for popup notifications
|
||||
- `PopUpWindow`: Displays AirPods status when case is opened
|
||||
- `PopUpPodViewFactory`: Creates UI components for different pod models
|
||||
|
||||
#### Common Utilities
|
||||
|
||||
- `EdgeToEdgeHelper`: Handles Android edge-to-edge display insets
|
||||
|
||||
### Build Configuration
|
||||
|
||||
#### Flavors
|
||||
|
||||
- **FOSS**: Open-source version without Google Play dependencies
|
||||
- **Google Play**: Version with billing client for in-app purchases
|
||||
|
||||
#### Build Types
|
||||
|
||||
- **debug**: Unobfuscated, full logging, no minification
|
||||
- **beta**: Obfuscated, production-ready with strict lint checks
|
||||
- **release**: Fully optimized for production distribution
|
||||
|
||||
### Data Flow Architecture
|
||||
|
||||
The app follows a unidirectional data flow:
|
||||
|
||||
1. `BluetoothEventReceiver` detects Bluetooth events
|
||||
2. `MonitorWorker` scans for AirPods beacon data
|
||||
3. `PodMonitor` processes and stores device information
|
||||
4. ViewModels observe monitor data via repositories
|
||||
5. UI components react to ViewModel state changes
|
||||
6. `ReactionSystem` triggers popups and notifications
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- **Unit Tests**: Located in `app-common/src/test/` for shared logic
|
||||
- **Test Flavors**: Separate test configurations for FOSS and Google Play variants
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
- **Hilt**: Dependency injection framework
|
||||
- **AndroidX Navigation**: Fragment navigation with SafeArgs
|
||||
- **WorkManager**: Background task scheduling for monitoring
|
||||
- **Moshi**: JSON serialization for configuration and debugging
|
||||
- **Material Design**: UI components following Material Design guidelines
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Bluetooth LE Implementation
|
||||
|
||||
The app uses Android's Bluetooth LE APIs to scan for Apple device advertisements. The core scanning logic is in
|
||||
`MonitorWorker` which runs as a long-lived background task.
|
||||
|
||||
### Multi-Platform Considerations
|
||||
|
||||
Code shared between phone and Wear OS apps is placed in `app-common`. When modifying shared functionality, ensure
|
||||
compatibility across both platforms.
|
||||
+97
-86
@@ -1,43 +1,48 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.5)
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
addressable (2.8.0)
|
||||
public_suffix (>= 2.0.2, < 5.0)
|
||||
artifactory (3.0.15)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.610.0)
|
||||
aws-sdk-core (3.131.3)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.525.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1109.0)
|
||||
aws-sdk-core (3.224.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.58.0)
|
||||
aws-sdk-core (~> 3, >= 3.127.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.114.0)
|
||||
aws-sdk-core (~> 3, >= 3.127.0)
|
||||
logger
|
||||
aws-sdk-kms (1.101.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.188.0)
|
||||
aws-sdk-core (~> 3, >= 3.224.1)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.4)
|
||||
aws-sigv4 (1.5.1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.6.4)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (2.7.6)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.92.4)
|
||||
faraday (1.10.0)
|
||||
excon (0.109.0)
|
||||
faraday (1.10.4)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
@@ -53,27 +58,27 @@ GEM
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (1.0.1)
|
||||
faraday-multipart (1.1.1)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.2.6)
|
||||
fastlane (2.208.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.228.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
@@ -82,33 +87,38 @@ GEM
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (~> 2.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (~> 0.1.1)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.3)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (>= 1.4.5, < 2.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.3.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.25.0)
|
||||
google-apis-core (>= 0.7, < 2.a)
|
||||
google-apis-core (0.7.0)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
@@ -116,99 +126,100 @@ GEM
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
webrick
|
||||
google-apis-iamcredentials_v1 (0.13.0)
|
||||
google-apis-core (>= 0.7, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.10.0)
|
||||
google-apis-core (>= 0.7, < 2.a)
|
||||
google-apis-storage_v1 (0.18.0)
|
||||
google-apis-core (>= 0.7, < 2.a)
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.29.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.6.1)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.2.0)
|
||||
google-cloud-storage (1.37.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.45.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.29.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.2.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.5)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.6.1)
|
||||
json (2.6.2)
|
||||
jwt (2.4.1)
|
||||
memoist (0.16.2)
|
||||
mini_magick (4.11.0)
|
||||
mini_mime (1.1.2)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.7.6)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.0.0)
|
||||
nanaimo (0.3.0)
|
||||
naturally (2.2.1)
|
||||
optparse (0.1.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.6.0)
|
||||
os (1.1.4)
|
||||
plist (3.6.0)
|
||||
public_suffix (4.0.7)
|
||||
rake (13.0.6)
|
||||
plist (3.7.2)
|
||||
public_suffix (5.1.1)
|
||||
rake (13.3.0)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.5)
|
||||
rouge (2.0.7)
|
||||
rexml (3.4.1)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
security (0.1.3)
|
||||
signet (0.17.0)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.18.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.8)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (1.8.0)
|
||||
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (1.8.0)
|
||||
webrick (1.7.0)
|
||||
unf (0.2.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.22.0)
|
||||
xcodeproj (1.27.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.3.0)
|
||||
rexml (~> 3.2.4)
|
||||
xcpretty (0.3.0)
|
||||
rouge (~> 2.0.7)
|
||||
nanaimo (~> 0.4.0)
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
universal-darwin-24
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
|
||||
+35
-9
@@ -1,5 +1,11 @@
|
||||
---
|
||||
layout: plain
|
||||
permalink: /privacy
|
||||
title: "Privacy Policy"
|
||||
---
|
||||
|
||||
# Privacy policy
|
||||
This is the privacy policy for the Android app "CAPod - Companion for AirPods".
|
||||
This is the privacy policy for the Android app "CAPod - Companion for AirPods" by Matthias Urhahn (darken).
|
||||
|
||||
## Preamble
|
||||
CAPod respects your privacy.
|
||||
@@ -20,23 +26,43 @@ On Android 12+ the newer and more fine grained `BLUETOOTH_SCAN` permission is us
|
||||
|
||||
Bluetooth Low Energy is a technology that devices like AirPods use to communicate their status to nearby devices.
|
||||
CAPod requests location permissions because these permissions are required to work with Bluetooth Low Energy data.
|
||||
This is a privacy measure on Android's side because you could determine someones location by scanning for Bluetooth devices:
|
||||
If you know the physical location of a Bluetooth device (e.g. AirTags) you could use Bluetooth data to calculate your position.
|
||||
This is a privacy measure on Android's side because you could determine someones location by scanning for Bluetooth
|
||||
devices:
|
||||
If you know the physical location of a Bluetooth device (e.g. AirTags) you could use Bluetooth data to calculate your
|
||||
position.
|
||||
|
||||
### Location access in the background
|
||||
|
||||
CAPod uses the "location access in the background" permission (`ACCESS_BACKGROUND_LOCATION`) on Android 11 and older to receive Bluetooth Low Energy data while the app is in the background. This permission enables the "Show popup" and "Autoconnect" features and allows CAPod to react to nearby devices whil the app is closed.
|
||||
CAPod uses the "location access in the background" permission (`ACCESS_BACKGROUND_LOCATION`) on Android 11 and older to
|
||||
receive Bluetooth Low Energy data while the app is in the background. This permission enables the "Show popup" and "
|
||||
Autoconnect" features and allows CAPod to react to nearby devices whil the app is closed.
|
||||
|
||||
## Automatic crash reports
|
||||
## Automatic error reports
|
||||
|
||||
Anonymous device information may be collected in the event of an app crash or error.
|
||||
*This was removed in v2.11.0+*
|
||||
|
||||
To do this the app uses the service "Bugsnag":
|
||||
If an error occurs, an automated report may be sent to help me fix the issue.
|
||||
|
||||
This is optional and you can opt out of this in the settings.
|
||||
|
||||
Error reports are collected using "Bugsnag":
|
||||
https://www.bugsnag.com/
|
||||
|
||||
Bugsnag's privacy policy can be found here:
|
||||
https://docs.bugsnag.com/legal/privacy-policy/
|
||||
|
||||
Crash reports may contain device and app related information, e.g. your phone model, Android version and app version.
|
||||
Error reports contain device and app information related to the error that occured.
|
||||
Additional information about the error context may also be included, e.g. what this app did shortly before the error.
|
||||
|
||||
You can disable automatic reports in the app's settings.
|
||||
Additional details about the type of data that is collected by the error tracking SDK can be found here:
|
||||
https://docs.bugsnag.com/platforms/android/playstore-privacy/
|
||||
|
||||
Error reports are pseudonymous. Unless you tell me your install-ID, I don't know that an error report came from you.
|
||||
|
||||
Error reports are automatically deleted after 90 days.
|
||||
|
||||
## Debug logs
|
||||
|
||||
The app has a debug log feature that can be used to assist troubleshooting efforts. This feature creates a log file that contains verbose output of what the app is doing.
|
||||
|
||||
It is manually triggered by the user through an option in the app settings. The recorded log file can be shared through compatible apps (e.g. your email app) using the system's share dialog. As this log file may contain sensitive information (e.g. details about files or installed applications) it should only be shared with trusted parties.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400">
|
||||
|
||||
# Companion App for AirPods (CAPod)
|
||||
|
||||
[](https://github.com/d4rken-org/capod/releases/latest)
|
||||
[](https://github.com/d4rken/capod/actions/workflows/code-checks.yml)
|
||||
[](https://crowdin.com/project/capod)
|
||||
[](https://github.com/d4rken-org/capod/releases/latest)
|
||||
[](https://github.com/d4rken-org/capod/edit/main/README.md#download)
|
||||
[](https://github.com/d4rken-org/capod/edit/main/README.md#download)
|
||||
|
||||
A companion app that adds support for AirPod specific features to Android:
|
||||
|
||||
@@ -25,11 +27,16 @@ Currently supported models:
|
||||
* AirPods 1. Generation
|
||||
* AirPods 2. Generation
|
||||
* AirPods 3. Generation
|
||||
* AirPods 4. Generation
|
||||
* AirPods 4. Generation with ANC
|
||||
* AirPods Pro 1. Generation
|
||||
* AirPods Pro 2. Generation
|
||||
* AirPods Pro 2. Generation (USB-C)
|
||||
* AirPods Pro 3. Generation
|
||||
* AirPods Max
|
||||
* Power Beats Pro
|
||||
* Power Beats 3
|
||||
* Power Beats 4
|
||||
* Beats Solo 3
|
||||
* Beats Studio 3
|
||||
* Beats X
|
||||
@@ -43,13 +50,13 @@ Currently supported models:
|
||||
|
||||
## Download
|
||||
|
||||
| Source | Status |
|
||||
|------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Source | Status |
|
||||
|------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [](https://play.google.com/store/apps/details?id=eu.darken.capod) |
|
||||
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [](https://play.google.com/apps/testing/eu.darken.capod) | |
|
||||
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [](https://github.com/d4rken-org/capod/releases/latest) |
|
||||
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [](https://apt.izzysoft.de/packages/eu.darken.capod/) |
|
||||
|
||||
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [](https://play.google.com/apps/testing/eu.darken.capod) | |
|
||||
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [](https://github.com/d4rken-org/capod/releases/latest) |
|
||||
| [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [](https://f-droid.org/en/packages/eu.darken.capod/) |
|
||||
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [](https://apt.izzysoft.de/packages/eu.darken.capod/) |
|
||||
|
||||
## Support the project
|
||||
|
||||
@@ -66,6 +73,7 @@ Currently supported models:
|
||||
|
||||
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
|
||||
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
|
||||
|
||||
## Thanks to
|
||||
|
||||
* The [OpenPods](https://github.com/adolfintel/OpenPods) project,
|
||||
@@ -74,6 +82,10 @@ Currently supported models:
|
||||
Travis & Rye, Erik & Sipes, Brandon & Teplov, Sam. (2019). Handoff All Your Privacy â A Review of Appleâs Bluetooth
|
||||
Low Energy Continuity Protocol. Proceedings on Privacy Enhancing Technologies. 2019. 34-53. 10.2478/popets-2019-0057.
|
||||
* Celosia, Guillaume. (2020). Privacy Challenges in Wireless Communications of the Internet of Things.
|
||||
* The [MagicPods](https://github.com/steam3d/MagicPods-Windows) project. If you are looking for "CAPod for Windows",
|
||||
check it out.
|
||||
* [@kavishdevar](https://github.com/kavishdevar/librepods) and
|
||||
his [LibrePods project](https://github.com/kavishdevar/librepods) for sharing a lot of cool stuff.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
theme: minima
|
||||
plugins:
|
||||
- jekyll-relative-links
|
||||
- jekyll-github-metadata
|
||||
- jemoji
|
||||
|
||||
relative_links:
|
||||
enabled: true
|
||||
collections: true
|
||||
|
||||
title: "CAPod"
|
||||
description: "A companion app for AirPods on Android."
|
||||
author: "by Matthias Urhahn"
|
||||
|
||||
include:
|
||||
- PRIVACY_POLICY.md
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
exclude:
|
||||
- buildSrc
|
||||
- gradle/wrapper
|
||||
- fastlane
|
||||
- Gemfile
|
||||
- Gemfile.lock
|
||||
- crowdin*
|
||||
- app
|
||||
- app-common
|
||||
- app-wear
|
||||
- CONTRIBUTING.md
|
||||
- CLAUDE.md
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ page.lang | default: site.lang | default: 'en' }}">
|
||||
|
||||
{%- include head.html -%}
|
||||
|
||||
<body>
|
||||
|
||||
<main class="page-content" aria-label="Content">
|
||||
<div class="wrapper">
|
||||
{{ content }}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{%- include footer.html -%}
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
id("com.google.devtools.ksp")
|
||||
id("kotlin-kapt")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
@@ -8,10 +9,10 @@ apply(plugin = "dagger.hilt.android.plugin")
|
||||
|
||||
android {
|
||||
compileSdk = ProjectConfig.compileSdk
|
||||
namespace = "${ProjectConfig.packageName}.common"
|
||||
|
||||
defaultConfig {
|
||||
minSdk = ProjectConfig.minSdk
|
||||
targetSdk = ProjectConfig.targetSdk
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
@@ -20,11 +21,6 @@ android {
|
||||
buildConfigField("String", "VERSION_NAME", "\"${ProjectConfig.Version.name}\"")
|
||||
buildConfigField("String", "APPLICATION_ID", "\"${ProjectConfig.packageName}\"")
|
||||
buildConfigField("String", "GITSHA", "\"${lastCommitHash()}\"")
|
||||
buildConfigField("String", "BUILDTIME", "\"${buildTime()}\"")
|
||||
|
||||
manifestPlaceholders["bugsnagApiKey"] = getBugSnagApiKey(
|
||||
File(System.getProperty("user.home"), ".appconfig/${ProjectConfig.packageName}/bugsnag.properties")
|
||||
) ?: "bugsnag_apikey_placeholder"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
@@ -33,12 +29,12 @@ android {
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
jvmTarget = "17"
|
||||
freeCompilerArgs = freeCompilerArgs + listOf(
|
||||
"-opt-in=kotlin.ExperimentalStdlibApi",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
@@ -80,6 +76,7 @@ android {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
//noinspection WrongGradleMethod
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -87,7 +84,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
|
||||
addBaseAndroid()
|
||||
addBaseAndroidUi()
|
||||
@@ -99,7 +96,4 @@ dependencies {
|
||||
|
||||
addTesting()
|
||||
|
||||
"gplayImplementation"("com.bugsnag:bugsnag-android:5.9.2")
|
||||
"gplayImplementation"("com.getkeepsafe.relinker:relinker:1.4.3")
|
||||
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest>
|
||||
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="com.bugsnag.android.API_KEY"
|
||||
android:value="${bugsnagApiKey}" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,58 +0,0 @@
|
||||
package eu.darken.capod.debug.autoreport
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import com.bugsnag.android.Event
|
||||
import com.bugsnag.android.OnErrorCallback
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
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 javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class BugsnagErrorHandler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val bugsnagLogger: BugsnagLogger,
|
||||
private val debugSettings: DebugSettings,
|
||||
) : OnErrorCallback {
|
||||
|
||||
override fun onError(event: Event): Boolean {
|
||||
bugsnagLogger.injectLog(event)
|
||||
|
||||
TAB_APP.also { tab ->
|
||||
event.addMetadata(tab, "gitSha", BuildConfigWrap.GIT_SHA)
|
||||
event.addMetadata(tab, "buildTime", BuildConfigWrap.BUILDTIME)
|
||||
|
||||
context.tryFormattedSignature()?.let { event.addMetadata(tab, "signatures", it) }
|
||||
}
|
||||
|
||||
return debugSettings.isAutoReportingEnabled.value && !eu.darken.capod.common.BuildConfigWrap.DEBUG
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAB_APP = "app"
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
fun Context.tryFormattedSignature(): String? = try {
|
||||
packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES).signatures?.let { sigs ->
|
||||
val sb = StringBuilder("[")
|
||||
for (i in sigs.indices) {
|
||||
sb.append(sigs[i].hashCode())
|
||||
if (i + 1 != sigs.size) sb.append(", ")
|
||||
}
|
||||
sb.append("]")
|
||||
sb.toString()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log(WARN) { e.asLog() }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package eu.darken.capod.debug.autoreport
|
||||
|
||||
import com.bugsnag.android.Event
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import java.lang.String.format
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class BugsnagLogger @Inject constructor() : Logging.Logger {
|
||||
|
||||
// Adding one to the initial size accounts for the add before remove.
|
||||
private val buffer: Deque<String> = ArrayDeque(BUFFER_SIZE + 1)
|
||||
|
||||
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
|
||||
val line = "${System.currentTimeMillis()} ${priority.toLabel()}/$tag: $message"
|
||||
synchronized(buffer) {
|
||||
buffer.addLast(line)
|
||||
if (buffer.size > BUFFER_SIZE) {
|
||||
buffer.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun injectLog(event: Event) {
|
||||
synchronized(buffer) {
|
||||
var i = 100
|
||||
buffer.forEach { event.addMetadata("Log", format(Locale.ROOT, "%03d", i++), it) }
|
||||
event.addMetadata("Log", format(Locale.ROOT, "%03d", i), event.originalError?.asLog())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BUFFER_SIZE = 200
|
||||
|
||||
private fun Logging.Priority.toLabel(): String = when (this) {
|
||||
Logging.Priority.VERBOSE -> "V"
|
||||
Logging.Priority.DEBUG -> "D"
|
||||
Logging.Priority.INFO -> "I"
|
||||
Logging.Priority.WARN -> "W"
|
||||
Logging.Priority.ERROR -> "E"
|
||||
Logging.Priority.ASSERT -> "WTF"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,15 @@ package eu.darken.capod.debug.autoreport
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.bugsnag.android.Bugsnag
|
||||
import com.bugsnag.android.Configuration
|
||||
import com.getkeepsafe.relinker.ReLinker
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.InstallId
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
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 javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
@@ -23,9 +18,6 @@ class GplayAutoReporting @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val debugSettings: DebugSettings,
|
||||
private val installId: InstallId,
|
||||
private val bugsnagLogger: Provider<BugsnagLogger>,
|
||||
private val bugsnagErrorHandler: Provider<BugsnagErrorHandler>,
|
||||
private val nopBugsnagErrorHandler: Provider<NOPBugsnagErrorHandler>,
|
||||
) : AutomaticBugReporter {
|
||||
|
||||
override fun setup(application: Application) {
|
||||
@@ -34,35 +26,13 @@ class GplayAutoReporting @Inject constructor(
|
||||
|
||||
if (!isEnabled) return
|
||||
|
||||
ReLinker
|
||||
.log { message -> log(TAG) { "ReLinker: $message" } }
|
||||
.loadLibrary(application, "bugsnag-plugin-android-anr")
|
||||
// Currently no 3rd party bug tracking
|
||||
|
||||
try {
|
||||
val bugsnagConfig = Configuration.load(context).apply {
|
||||
if (debugSettings.isAutoReportingEnabled.value) {
|
||||
Logging.install(bugsnagLogger.get())
|
||||
setUser(installId.id, null, null)
|
||||
autoTrackSessions = true
|
||||
addOnError(bugsnagErrorHandler.get())
|
||||
addMetadata("App", "buildFlavor", BuildConfigWrap.FLAVOR)
|
||||
log(TAG) { "Bugsnag setup done!" }
|
||||
} else {
|
||||
autoTrackSessions = false
|
||||
addOnError(nopBugsnagErrorHandler.get())
|
||||
log(TAG) { "Installing Bugsnag NOP error handler due to user opt-out!" }
|
||||
}
|
||||
}
|
||||
|
||||
Bugsnag.start(context, bugsnagConfig)
|
||||
Bugs.reporter = this
|
||||
} catch (e: IllegalStateException) {
|
||||
log(TAG) { "Bugsnag API Key not configured." }
|
||||
}
|
||||
Bugs.reporter = this
|
||||
}
|
||||
|
||||
override fun notify(throwable: Throwable) {
|
||||
Bugsnag.notify(throwable)
|
||||
log(TAG, WARN) { "notify($throwable)" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package eu.darken.capod.debug.autoreport
|
||||
|
||||
import com.bugsnag.android.Event
|
||||
import com.bugsnag.android.OnErrorCallback
|
||||
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 javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NOPBugsnagErrorHandler @Inject constructor() : OnErrorCallback {
|
||||
|
||||
override fun onError(event: Event): Boolean {
|
||||
log(WARN) { "Error, but skipping bugsnag due to user opt-out: ${event.originalError?.asLog()}" }
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import android.content.BroadcastReceiver
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
|
||||
fun BroadcastReceiver.PendingResult.finish2(): Boolean = try {
|
||||
finish()
|
||||
true
|
||||
} catch (e: IllegalStateException) {
|
||||
log { "BroadcastReceiver.PendingResult.finish() failed: $e" }
|
||||
false
|
||||
}
|
||||
@@ -37,7 +37,6 @@ object BuildConfigWrap {
|
||||
val VERSION_CODE: Long = BuildConfig.VERSION_CODE.toLong()
|
||||
val VERSION_NAME: String = BuildConfig.VERSION_NAME
|
||||
val GIT_SHA: String = BuildConfig.GITSHA
|
||||
val BUILDTIME: String = BuildConfig.BUILDTIME
|
||||
|
||||
val VERSION_DESCRIPTION_LONG: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA] ${FLAVOR}_$BUILD_TYPE"
|
||||
val VERSION_DESCRIPTION_SHORT: String = "v$VERSION_NAME [$GIT_SHA] $FLAVOR"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import java.util.*
|
||||
import java.util.BitSet
|
||||
|
||||
fun Byte.toHex(): String = String.format("%02X", this)
|
||||
fun UByte.toHex(): String = this.toByte().toHex()
|
||||
@@ -18,3 +18,12 @@ fun UShort.isBitSet(pos: Int): Boolean = this.toShort().isBitSet(pos)
|
||||
|
||||
fun UShort.toBinaryString(): String = Integer.toBinaryString(this.toInt()).padStart(4, '0')
|
||||
fun UByte.toBinaryString(): String = Integer.toBinaryString(this.toInt()).padStart(8, '0')
|
||||
|
||||
|
||||
fun ByteArray.toHex(separator: String = "-"): String = joinToString(separator) { "%02X".format(it) }
|
||||
fun String.fromHex(): ByteArray = this
|
||||
.replace(" ", "")
|
||||
.replace("-", "")
|
||||
.also { require(it.length % 2 == 0) { "Not a HEX string, length: ${it.length}" } }
|
||||
.chunked(2).map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
object PrivacyPolicy {
|
||||
const val URL = "https://github.com/d4rken-org/capod/blob/main/PRIVACY_POLICY.md"
|
||||
const val URL = "https://capod.darken.eu/privacy"
|
||||
}
|
||||
@@ -2,10 +2,11 @@ package eu.darken.capod.common
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -15,13 +16,13 @@ class WebpageTool @Inject constructor(
|
||||
) {
|
||||
|
||||
fun open(address: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(address)).apply {
|
||||
val intent = Intent(Intent.ACTION_VIEW, address.toUri()).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
log(ERROR) { "Failed to launch" }
|
||||
log(ERROR) { "Failed to launch: ${e.asLog()}" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,19 @@ import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -32,9 +38,9 @@ class BleScanner @Inject constructor(
|
||||
@SuppressLint("MissingPermission") fun scan(
|
||||
filters: Set<ScanFilter>,
|
||||
scannerMode: ScannerMode = ScannerMode.BALANCED,
|
||||
disableOffloadFiltering: Boolean = true,
|
||||
disableOffloadBatching: Boolean = true,
|
||||
disableDirectScanCallback: Boolean = true,
|
||||
disableOffloadFiltering: Boolean = false,
|
||||
disableOffloadBatching: Boolean = false,
|
||||
disableDirectScanCallback: Boolean = false,
|
||||
): Flow<Collection<BleScanResult>> = callbackFlow {
|
||||
log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" }
|
||||
|
||||
@@ -132,12 +138,12 @@ class BleScanner @Inject constructor(
|
||||
ScannerMode.LOW_POWER -> {
|
||||
setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
|
||||
setMatchMode(ScanSettings.MATCH_MODE_STICKY)
|
||||
setNumOfMatches(ScanSettings.MATCH_NUM_FEW_ADVERTISEMENT)
|
||||
setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
|
||||
}
|
||||
ScannerMode.BALANCED -> {
|
||||
setScanMode(ScanSettings.SCAN_MODE_BALANCED)
|
||||
setMatchMode(ScanSettings.MATCH_MODE_STICKY)
|
||||
setNumOfMatches(ScanSettings.MATCH_NUM_FEW_ADVERTISEMENT)
|
||||
setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
|
||||
}
|
||||
ScannerMode.LOW_LATENCY -> {
|
||||
setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
typealias BluetoothAddress = String
|
||||
@@ -0,0 +1,15 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import java.time.Instant
|
||||
|
||||
data class BluetoothDevice2(
|
||||
internal val internal: BluetoothDevice,
|
||||
val seenFirstAt: Instant,
|
||||
) {
|
||||
val address: BluetoothAddress
|
||||
get() = internal.address
|
||||
|
||||
val name: String?
|
||||
get() = internal.name
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import android.bluetooth.*
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothHeadset
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.le.BluetoothLeScanner
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -12,14 +16,24 @@ import android.os.ParcelUuid
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -74,7 +88,7 @@ class BluetoothManager2 @Inject constructor(
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {
|
||||
log(TAG, WARN) { "onServiceDisconnected(profile=$profile" }
|
||||
log(TAG, WARN) { "onServiceDisconnected(profile=$profile)" }
|
||||
close(IOException("BluetoothProfile service disconnected (profile=$profile)"))
|
||||
}
|
||||
|
||||
@@ -128,6 +142,7 @@ class BluetoothManager2 @Inject constructor(
|
||||
log(TAG) { "Adding $device to current devices $currentDevices" }
|
||||
trySend(currentDevices.plus(device))
|
||||
}
|
||||
|
||||
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
|
||||
log(TAG) { "Removing $device from current devices $currentDevices" }
|
||||
trySend(currentDevices.minus(device))
|
||||
@@ -145,23 +160,54 @@ class BluetoothManager2 @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private val seenDevicesLock = Mutex()
|
||||
private val seenDevicesCache = mutableMapOf<String, Instant>()
|
||||
|
||||
fun connectedDevices(
|
||||
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
|
||||
): Flow<List<BluetoothDevice>> = isBluetoothEnabled
|
||||
): Flow<List<BluetoothDevice2>> = isBluetoothEnabled
|
||||
.flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
|
||||
.map { devices ->
|
||||
devices.filter { device ->
|
||||
featureFilter.any { feature ->
|
||||
device.hasFeature(feature)
|
||||
}
|
||||
val currentAddresses = devices.map { it.address }
|
||||
|
||||
seenDevicesLock.withLock {
|
||||
val cleanedCache = seenDevicesCache.filterKeys { currentAddresses.contains(it) }
|
||||
seenDevicesCache.clear()
|
||||
seenDevicesCache.putAll(cleanedCache)
|
||||
}
|
||||
|
||||
devices
|
||||
.filter { device -> featureFilter.any { feature -> device.hasFeature(feature) } }
|
||||
.map { device ->
|
||||
BluetoothDevice2(
|
||||
internal = device,
|
||||
seenFirstAt = seenDevicesLock.withLock {
|
||||
seenDevicesCache[device.address] ?: Instant.now().also {
|
||||
seenDevicesCache[device.address] = it
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun bondedDevices(): Flow<Set<BluetoothDevice>> = flow {
|
||||
emit(adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable"))
|
||||
fun bondedDevices(): Flow<Set<BluetoothDevice2>> = flow {
|
||||
val rawDevices = adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable")
|
||||
val wrappedDevices = rawDevices.map { device ->
|
||||
|
||||
BluetoothDevice2(
|
||||
internal = device,
|
||||
seenFirstAt = seenDevicesLock.withLock {
|
||||
seenDevicesCache[device.address] ?: Instant.now().also {
|
||||
seenDevicesCache[device.address] = it
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
}.toSet()
|
||||
emit(wrappedDevices)
|
||||
}
|
||||
|
||||
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
||||
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
||||
try {
|
||||
log(TAG) { "Nudging Android connection to $device" }
|
||||
|
||||
@@ -169,7 +215,7 @@ class BluetoothManager2 @Inject constructor(
|
||||
"connect", BluetoothDevice::class.java
|
||||
).apply { isAccessible = true }
|
||||
|
||||
connectMethod.invoke(bluetoothProfile.proxy, device)
|
||||
connectMethod.invoke(bluetoothProfile.proxy, device.internal)
|
||||
|
||||
log(TAG) { "Nudged connection to $device" }
|
||||
true
|
||||
|
||||
@@ -3,6 +3,7 @@ package eu.darken.capod.common.bluetooth
|
||||
import dagger.Reusable
|
||||
import eu.darken.capod.common.SystemClockWrap
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.fromHex
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import kotlin.random.Random
|
||||
@@ -25,7 +26,7 @@ class FakeBleData @Inject constructor(
|
||||
address = "78:73:AF:B4:85:22",
|
||||
rssi = Random.nextInt(100) * -1,
|
||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
@@ -36,7 +37,7 @@ class FakeBleData @Inject constructor(
|
||||
address = "78:73:FF:B4:85:5E",
|
||||
rssi = Random.nextInt(100) * -1,
|
||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
@@ -47,7 +48,7 @@ class FakeBleData @Inject constructor(
|
||||
address = "4E:9E:D1:49:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 200,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".hexToByteArray())
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
@@ -57,7 +58,7 @@ class FakeBleData @Inject constructor(
|
||||
address = "7E:E5:C7:65:D2:B5",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 300,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".hexToByteArray())
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
@@ -67,7 +68,7 @@ class FakeBleData @Inject constructor(
|
||||
address = "5E:9E:D1:49:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
@@ -78,7 +79,7 @@ class FakeBleData @Inject constructor(
|
||||
address = "5E:9E:D1:29:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
|
||||
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".hexToByteArray())
|
||||
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
@@ -89,22 +90,11 @@ class FakeBleData @Inject constructor(
|
||||
address = "6E:9E:D1:49:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 500,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
|
||||
return fakeDevices
|
||||
}
|
||||
|
||||
|
||||
private fun String.hexToByteArray(): ByteArray {
|
||||
val trimmed = this
|
||||
.replace(" ", "")
|
||||
.replace(">", "")
|
||||
.replace("<", "")
|
||||
require(trimmed.length % 2 == 0) { "Not a HEX string" }
|
||||
return trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package eu.darken.capod.common.preferences
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
|
||||
class MaterialSwitchPreference(context: Context, attrs: AttributeSet?) :
|
||||
SwitchPreferenceCompat(context, attrs) {
|
||||
|
||||
init {
|
||||
// Use material switch
|
||||
widgetLayoutResource = eu.darken.capod.common.R.layout.preference_material_switch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package eu.darken.capod.common.serialization
|
||||
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.ToJson
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Suppress("unused")
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
class ByteArrayAdapter {
|
||||
@ToJson
|
||||
fun toJson(obj: ByteArray): String = Base64.encode(obj)
|
||||
|
||||
@FromJson
|
||||
fun fromJson(base64: String): ByteArray? = Base64.decode(base64)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class SerializationModule {
|
||||
@Singleton
|
||||
fun moshi(): Moshi = Moshi.Builder()
|
||||
.add(JavaInstantAdapter())
|
||||
.add(ByteArrayAdapter())
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import eu.darken.capod.common.error.ErrorEventSource
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.common.livedata.SingleLiveEvent
|
||||
import eu.darken.capod.common.navigation.NavEventSource
|
||||
import eu.darken.capod.common.navigation.navVia
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
@@ -31,4 +32,7 @@ abstract class ViewModel3(
|
||||
.setupCommonEventHandlers(TAG) { "launchInViewModel()" }
|
||||
.launchIn(vmScope)
|
||||
|
||||
fun NavDirections.navigate() {
|
||||
navVia(navEvents)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ interface UpgradeRepo {
|
||||
val isPro: Boolean
|
||||
|
||||
val upgradedAt: Instant?
|
||||
|
||||
val error: Throwable?
|
||||
}
|
||||
|
||||
enum class Type {
|
||||
|
||||
@@ -5,12 +5,15 @@ import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceDataStore
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.preferences.PreferenceStoreMapper
|
||||
import eu.darken.capod.common.preferences.Settings
|
||||
import eu.darken.capod.common.preferences.createFlowPreference
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -24,14 +27,30 @@ class GeneralSettings @Inject constructor(
|
||||
override val preferences: SharedPreferences = context.getSharedPreferences("settings_general", Context.MODE_PRIVATE)
|
||||
|
||||
val monitorMode = preferences.createFlowPreference("core.monitor.mode", MonitorMode.AUTOMATIC, moshi)
|
||||
|
||||
val useExtraMonitorNotification = preferences.createFlowPreference("core.monitor.notification.connected", false)
|
||||
|
||||
val keepConnectedNotificationAfterDisconnect =
|
||||
preferences.createFlowPreference("core.monitor.notification.connected.keepafterdisconnected", false)
|
||||
|
||||
val scannerMode = preferences.createFlowPreference("core.scanner.mode", ScannerMode.BALANCED, moshi)
|
||||
|
||||
val showAll = preferences.createFlowPreference("core.showall.enabled", true)
|
||||
|
||||
val minimumSignalQuality = preferences.createFlowPreference("core.signal.minimum", 0.25f)
|
||||
val minimumSignalQuality = preferences.createFlowPreference("core.signal.minimum", 0.20f)
|
||||
|
||||
val mainDeviceAddress = preferences.createFlowPreference<String?>("core.maindevice.address", null)
|
||||
val mainDeviceAddress = preferences.createFlowPreference<BluetoothAddress?>("core.maindevice.address", null)
|
||||
val mainDeviceModel = preferences.createFlowPreference("core.maindevice.model", PodDevice.Model.UNKNOWN, moshi)
|
||||
val mainDeviceIdentityKey = preferences.createFlowPreference<IdentityResolvingKey?>(
|
||||
"core.maindevice.identitykey",
|
||||
null,
|
||||
moshi
|
||||
)
|
||||
val mainDeviceEncryptionKey = preferences.createFlowPreference<ProximityEncryptionKey?>(
|
||||
"core.maindevice.encryptionkey",
|
||||
null,
|
||||
moshi
|
||||
)
|
||||
|
||||
val isOffloadedFilteringDisabled = preferences.createFlowPreference(
|
||||
"core.compat.offloaded.filtering.disabled",
|
||||
@@ -40,8 +59,12 @@ class GeneralSettings @Inject constructor(
|
||||
val isOffloadedBatchingDisabled = preferences.createFlowPreference("core.compat.offloaded.batching.disabled", false)
|
||||
val useIndirectScanResultCallback = preferences.createFlowPreference("core.compat.indirectcallback.enabled", false)
|
||||
|
||||
val isOnboardingDone = preferences.createFlowPreference("core.onboarding.done", false)
|
||||
|
||||
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
|
||||
monitorMode,
|
||||
useExtraMonitorNotification,
|
||||
keepConnectedNotificationAfterDisconnect,
|
||||
scannerMode,
|
||||
showAll,
|
||||
minimumSignalQuality,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import dagger.BindsInstance
|
||||
import dagger.hilt.DefineComponent
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@MonitorScope
|
||||
@DefineComponent(parent = SingletonComponent::class)
|
||||
interface MonitorComponent {
|
||||
|
||||
@DefineComponent.Builder
|
||||
interface Builder {
|
||||
|
||||
fun coroutineScope(@BindsInstance coroutineScope: MonitorCoroutineScope): Builder
|
||||
|
||||
fun build(): MonitorComponent
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@InstallIn(MonitorComponent::class)
|
||||
@Module()
|
||||
abstract class ProcessorModule {
|
||||
|
||||
@Binds
|
||||
@MonitorScope
|
||||
abstract fun processorScope(scope: MonitorCoroutineScope): CoroutineScope
|
||||
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@MustBeDocumented
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class MonitorScope
|
||||
@@ -14,14 +14,23 @@ 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.common.flow.throttleLatest
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.PodFactory
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.retryWhen
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.time.Duration
|
||||
@@ -35,10 +44,10 @@ class PodMonitor @Inject constructor(
|
||||
private val bleScanner: BleScanner,
|
||||
private val podFactory: PodFactory,
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
private val debugSettings: DebugSettings,
|
||||
private val podDeviceCache: PodDeviceCache,
|
||||
private val permissionTool: PermissionTool,
|
||||
permissionTool: PermissionTool,
|
||||
) {
|
||||
|
||||
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
|
||||
@@ -113,12 +122,14 @@ class PodMonitor @Inject constructor(
|
||||
disableDirectCallback = useIndirectScanResultCallback,
|
||||
)
|
||||
}
|
||||
.throttleLatest(1000)
|
||||
.flatMapLatest { options ->
|
||||
val filters = when {
|
||||
options.showUnfiltered -> {
|
||||
log(TAG, WARN) { "Using unfiltered scan mode" }
|
||||
setOf(ScanFilter.Builder().build())
|
||||
}
|
||||
|
||||
else -> ProximityPairing.getBleScanFilter()
|
||||
}
|
||||
|
||||
@@ -187,8 +198,14 @@ class PodMonitor @Inject constructor(
|
||||
}
|
||||
|
||||
private fun determineMainDevice(pods: List<PodDevice>): PodDevice? {
|
||||
val mainDeviceModel = generalSettings.mainDeviceModel.value
|
||||
val identityKey = generalSettings.mainDeviceIdentityKey.value?.takeIf { it.isNotEmpty() }
|
||||
if (identityKey != null) {
|
||||
val irkHit = pods.filterIsInstance<ApplePods>().firstOrNull { it.flags.isIRKMatch }
|
||||
log(TAG) { "IRK is configured, main device irkHit=$irkHit" }
|
||||
return irkHit
|
||||
}
|
||||
|
||||
val mainDeviceModel = generalSettings.mainDeviceModel.value
|
||||
val presorted = sortPodsToInterest(pods).sortedByDescending {
|
||||
it.model == mainDeviceModel && it.model != PodDevice.Model.UNKNOWN
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import eu.darken.capod.common.R
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -20,7 +21,7 @@ interface PodDevice {
|
||||
|
||||
val model: Model
|
||||
|
||||
val address: String
|
||||
val address: BluetoothAddress
|
||||
get() = scanResult.address
|
||||
|
||||
val seenLastAt: Instant
|
||||
@@ -83,6 +84,14 @@ interface PodDevice {
|
||||
"AirPods (Gen 3)",
|
||||
R.drawable.devic_airpods_gen2_both,
|
||||
),
|
||||
@Json(name = "airpods.gen4") AIRPODS_GEN4(
|
||||
"AirPods (Gen 4)",
|
||||
R.drawable.devic_airpods_gen2_both,
|
||||
),
|
||||
@Json(name = "airpods.gen4.anc") AIRPODS_GEN4_ANC(
|
||||
"AirPods (Gen 4 ANC)",
|
||||
R.drawable.devic_airpods_gen2_both,
|
||||
),
|
||||
@Json(name = "airpods.pro") AIRPODS_PRO(
|
||||
"AirPods Pro",
|
||||
R.drawable.devic_airpods_pro2_both
|
||||
@@ -91,10 +100,22 @@ interface PodDevice {
|
||||
"AirPods Pro 2",
|
||||
R.drawable.devic_airpods_pro2_both
|
||||
),
|
||||
@Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC(
|
||||
"AirPods Pro 2 USB-C",
|
||||
R.drawable.devic_airpods_pro2_both
|
||||
),
|
||||
@Json(name = "airpods.pro3") AIRPODS_PRO3(
|
||||
"AirPods Pro 3",
|
||||
R.drawable.devic_airpods_pro2_both
|
||||
),
|
||||
@Json(name = "airpods.max") AIRPODS_MAX(
|
||||
"AirPods Max",
|
||||
R.drawable.devic_headphones_generic
|
||||
),
|
||||
@Json(name = "airpods.max.usbc") AIRPODS_MAX_USBC(
|
||||
"AirPods Max USB-C",
|
||||
R.drawable.devic_headphones_generic
|
||||
),
|
||||
@Json(name = "beats.flex") BEATS_FLEX(
|
||||
"Beats Flex"
|
||||
),
|
||||
@@ -110,9 +131,15 @@ interface PodDevice {
|
||||
@Json(name = "beats.powerbeats.3") POWERBEATS_3(
|
||||
"Power Beats 3"
|
||||
),
|
||||
@Json(name = "beats.powerbeats.4") POWERBEATS_4(
|
||||
"Power Beats 4"
|
||||
),
|
||||
@Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
|
||||
"Power Beats Pro"
|
||||
),
|
||||
@Json(name = "beats.powerbeats.pro2") POWERBEATS_PRO2(
|
||||
"Power Beats Pro 2"
|
||||
),
|
||||
@Json(name = "beats.fit.pro") BEATS_FIT_PRO(
|
||||
"Beats Fit Pro"
|
||||
),
|
||||
|
||||
@@ -5,10 +5,14 @@ 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 eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
|
||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import eu.darken.capod.pods.core.apple.protocol.RPAChecker
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
@@ -18,13 +22,16 @@ import javax.inject.Singleton
|
||||
class AppleFactory @Inject constructor(
|
||||
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
|
||||
private val proximityPairingDecoder: ProximityPairing.Decoder,
|
||||
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory<out ApplePods>>,
|
||||
private val proximityMessageDecrypter: ProximityMessage.Decrypter,
|
||||
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory>,
|
||||
private val unknownAppleFactory: UnknownAppleDevice.Factory,
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val rpaChecker: RPAChecker,
|
||||
) {
|
||||
|
||||
private val lock = Mutex()
|
||||
|
||||
private fun getMessage(scanResult: BleScanResult): ProximityPairing.Message? {
|
||||
private fun getMessage(scanResult: BleScanResult): ProximityMessage? {
|
||||
val messages = try {
|
||||
continuityProtocolDecoder.decode(scanResult)
|
||||
} catch (e: Exception) {
|
||||
@@ -50,13 +57,39 @@ class AppleFactory @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
|
||||
val pm = getMessage(scanResult) ?: return@withLock null
|
||||
val proximityMessage = getMessage(scanResult) ?: return@withLock null
|
||||
|
||||
val isIrkMatch = generalSettings.mainDeviceIdentityKey.value
|
||||
?.let { rpaChecker.verify(scanResult.address, it) }
|
||||
?: false
|
||||
|
||||
val payload = ProximityPayload(
|
||||
public = ProximityPayload.Public(
|
||||
proximityMessage.data.take(9).toUByteArray()
|
||||
),
|
||||
private = run {
|
||||
if (!isIrkMatch) return@run null
|
||||
if (proximityMessage.data.size != ProximityPairing.PAIRING_MESSAGE_LENGTH) return@run null
|
||||
|
||||
val encKey = generalSettings.mainDeviceEncryptionKey.value?.takeIf { it.isNotEmpty() }
|
||||
if (encKey == null) return@run null
|
||||
|
||||
val encrypted = proximityMessage.data.takeLast(16).toUByteArray().toByteArray()
|
||||
proximityMessageDecrypter.decrypt(encrypted, encKey)?.let {
|
||||
ProximityPayload.Private(data = it)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) }
|
||||
|
||||
val factory = podFactories.firstOrNull { it.isResponsible(pm) }
|
||||
|
||||
return@withLock (factory ?: unknownAppleFactory).create(
|
||||
scanResult = scanResult,
|
||||
message = pm,
|
||||
payload = payload,
|
||||
flags = ApplePods.Flags(
|
||||
isIRKMatch = isIrkMatch,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,33 +5,62 @@ import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.pods.core.apple.airpods.*
|
||||
import eu.darken.capod.pods.core.apple.beats.*
|
||||
import eu.darken.capod.pods.core.apple.misc.*
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen1
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen2
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen3
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4Anc
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsMaxUsbc
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2Usbc
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro3
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsFitPro
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsFlex
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsSolo3
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsStudio3
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsX
|
||||
import eu.darken.capod.pods.core.apple.beats.PowerBeats3
|
||||
import eu.darken.capod.pods.core.apple.beats.PowerBeats4
|
||||
import eu.darken.capod.pods.core.apple.beats.PowerBeatsPro
|
||||
import eu.darken.capod.pods.core.apple.beats.PowerBeatsPro2
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen1
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen2
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen3
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro2
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
abstract class AppleFactoryModule {
|
||||
|
||||
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen4(factory: AirPodsGen4.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen4Anc(factory: AirPodsGen4Anc.Factory): ApplePodsFactory
|
||||
|
||||
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsPro3(factory: AirPodsPro3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory
|
||||
|
||||
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeats4(factory: PowerBeats4.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeatsPro2(factory: PowerBeatsPro2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory
|
||||
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory
|
||||
}
|
||||
@@ -1,48 +1,62 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.toHex
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
|
||||
interface ApplePods : PodDevice {
|
||||
|
||||
val proximityMessage: ProximityPairing.Message
|
||||
val payload: ProximityPayload
|
||||
val flags: Flags
|
||||
|
||||
// We start counting at the airpods prefix byte
|
||||
val rawPrefix: UByte
|
||||
get() = proximityMessage.data[0]
|
||||
val pubPrefix: UByte
|
||||
get() = payload.public.data[0]
|
||||
|
||||
val rawDeviceModel: UShort
|
||||
get() = (((proximityMessage.data[1].toInt() and 255) shl 8) or (proximityMessage.data[2].toInt() and 255)).toUShort()
|
||||
val pubDeviceModel: UShort
|
||||
get() = (((payload.public.data[1].toInt() and 255) shl 8) or (payload.public.data[2].toInt() and 255)).toUShort()
|
||||
|
||||
val rawStatus: UByte
|
||||
get() = proximityMessage.data[3]
|
||||
val pubStatus: UByte
|
||||
get() = payload.public.data[3]
|
||||
|
||||
val rawStatusHex: String
|
||||
get() = rawStatus.toHex()
|
||||
val pubPodsBattery: UByte
|
||||
get() = payload.public.data[4]
|
||||
|
||||
val rawPodsBattery: UByte
|
||||
get() = proximityMessage.data[4]
|
||||
val pubFlags: UShort
|
||||
get() = payload.public.data[5].upperNibble
|
||||
|
||||
val rawPodsBatteryHex: String
|
||||
get() = rawPodsBattery.toHex()
|
||||
val pubCaseBattery: UShort
|
||||
get() = payload.public.data[5].lowerNibble
|
||||
|
||||
val rawFlags: UShort
|
||||
get() = proximityMessage.data[5].upperNibble
|
||||
val pubCaseLidState: UByte
|
||||
get() = payload.public.data[6]
|
||||
|
||||
val rawCaseBattery: UShort
|
||||
get() = proximityMessage.data[5].lowerNibble
|
||||
val pubDeviceColor: UByte
|
||||
get() = payload.public.data[7]
|
||||
|
||||
val rawCaseLidState: UByte
|
||||
get() = proximityMessage.data[6]
|
||||
val pubSuffix: UByte
|
||||
get() = payload.public.data[8]
|
||||
|
||||
val rawDeviceColor: UByte
|
||||
get() = proximityMessage.data[7]
|
||||
data class BatteryState(
|
||||
val isCharging: Boolean,
|
||||
val level: Float,
|
||||
)
|
||||
|
||||
val rawSuffix: UByte
|
||||
get() = proximityMessage.data[8]
|
||||
fun ProximityPayload.Private.asBatteryState(pos: Int): BatteryState? {
|
||||
val raw = data[pos]
|
||||
val level = (raw and 0x7Fu).toInt() / 100f
|
||||
if (level < 0f || level > 1.0f) return null
|
||||
|
||||
val isCharging = (raw and 0x80u).toInt() != 0
|
||||
|
||||
return BatteryState(
|
||||
isCharging = isCharging,
|
||||
level = level
|
||||
)
|
||||
}
|
||||
|
||||
data class Flags(
|
||||
val isIRKMatch: Boolean,
|
||||
)
|
||||
}
|
||||
@@ -1,108 +1,26 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.collections.median
|
||||
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.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlin.math.max
|
||||
import eu.darken.capod.pods.core.apple.history.KnownDevice
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
|
||||
abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
|
||||
interface ApplePodsFactory {
|
||||
fun isResponsible(message: ProximityMessage): Boolean
|
||||
fun create(scanResult: BleScanResult, payload: ProximityPayload, flags: ApplePods.Flags): ApplePods
|
||||
|
||||
data class Markings(
|
||||
val vendor: UByte,
|
||||
val length: UByte,
|
||||
val device: UShort,
|
||||
val podBatteryData: Set<UShort>,
|
||||
val caseBatteryData: UShort,
|
||||
val deviceColor: UByte,
|
||||
data class ModelInfo(
|
||||
val full: UShort,
|
||||
val dirty: UByte,
|
||||
)
|
||||
|
||||
private fun ProximityPairing.Message.getApplePodsMarkings(): Markings = Markings(
|
||||
vendor = ProximityPairing.CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING,
|
||||
length = ProximityPairing.PAIRING_MESSAGE_LENGTH.toUByte(),
|
||||
device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
|
||||
// Make comparison order independent
|
||||
podBatteryData = setOf(data[4].upperNibble, data[4].lowerNibble),
|
||||
caseBatteryData = data[5].lowerNibble,
|
||||
deviceColor = data[7]
|
||||
fun ProximityMessage.getModelInfo(): ModelInfo = ModelInfo(
|
||||
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
|
||||
dirty = data[1]
|
||||
)
|
||||
|
||||
data class KnownDevice(
|
||||
val id: PodDevice.Id,
|
||||
val seenFirstAt: Instant,
|
||||
val seenCounter: Int,
|
||||
val history: List<ApplePods>,
|
||||
val lastCaseBattery: Float?,
|
||||
) {
|
||||
val lastMessage: ProximityPairing.Message
|
||||
get() = history.last().proximityMessage
|
||||
|
||||
val lastAddress: String
|
||||
get() = history.last().address
|
||||
|
||||
val reliability: Float
|
||||
get() {
|
||||
if (history.size < 2) return 0f
|
||||
|
||||
val now = Instant.now()
|
||||
|
||||
val pingInterval = List(history.dropLast(1).size) { index ->
|
||||
Duration.between(history[index].seenLastAt, history[index + 1].seenLastAt).toMillis()
|
||||
}.map { it.toInt() }.median()
|
||||
|
||||
val expectedPings: Float = LOOKBACK.toMillis() / pingInterval.toFloat()
|
||||
|
||||
val recentPings = history.filter {
|
||||
Duration.between(it.seenLastAt, now).toMillis() < LOOKBACK.toMillis() + pingInterval
|
||||
}.size
|
||||
|
||||
val reliability: Float = (recentPings / expectedPings)
|
||||
// log("#####") { "interval=$pingInterval, expectedPerMinute=$expectedPings, count=$recentPings" }
|
||||
|
||||
return max(0.0f, reliability).coerceAtMost(1f)
|
||||
}
|
||||
|
||||
fun rssiSmoothed(latest: Int): Int {
|
||||
val now = Instant.now()
|
||||
return history
|
||||
.filter { Duration.between(it.seenLastAt, now) < LOOKBACK }
|
||||
.map { it.rssi }
|
||||
.plus(latest)
|
||||
.median()
|
||||
}
|
||||
|
||||
fun isOlderThan(age: Duration): Boolean {
|
||||
val now = Instant.now()
|
||||
return Duration.between(history.last().seenLastAt, now) > age
|
||||
}
|
||||
|
||||
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
|
||||
|
||||
companion object {
|
||||
// 30 seconds at fastest interval
|
||||
const val MAX_HISTORY = 60
|
||||
val LOOKBACK = Duration.ofSeconds(30)
|
||||
}
|
||||
}
|
||||
|
||||
internal val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
|
||||
|
||||
|
||||
fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery
|
||||
|
||||
private fun Collection<ApplePods>.determineLatestCaseBattery(): Float? = this
|
||||
.filterIsInstance<HasCase>()
|
||||
.mapNotNull { it.batteryCasePercent }
|
||||
.lastOrNull()
|
||||
|
||||
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
|
||||
val definitive = setOf(
|
||||
DualApplePods.LidState.OPEN,
|
||||
@@ -118,89 +36,4 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
|
||||
?.caseLidState
|
||||
?: DualApplePods.LidState.NOT_IN_CASE
|
||||
}
|
||||
|
||||
open fun historyTrimmer(
|
||||
pods: List<ApplePods>
|
||||
): List<ApplePods> {
|
||||
return pods.takeLast(KnownDevice.MAX_HISTORY)
|
||||
}
|
||||
|
||||
internal open fun searchHistory(current: PodType): KnownDevice? {
|
||||
val scanResult = current.scanResult
|
||||
val message = current.proximityMessage
|
||||
|
||||
knownDevices.values.toList().forEach { knownDevice ->
|
||||
if (knownDevice.isOlderThan(Duration.ofSeconds(30))) {
|
||||
log(tag, VERBOSE) { "searchHistory1: Removing stale known device: $knownDevice" }
|
||||
knownDevices.remove(knownDevice.id)
|
||||
}
|
||||
}
|
||||
|
||||
knownDevices.values
|
||||
.filter { it.history.size > KnownDevice.MAX_HISTORY }
|
||||
.toList()
|
||||
.forEach {
|
||||
knownDevices[it.id] = it.copy(history = historyTrimmer(it.history))
|
||||
}
|
||||
|
||||
var recognizedDevice: KnownDevice? = knownDevices.values
|
||||
.firstOrNull { it.lastAddress == scanResult.address }
|
||||
?.also { log(tag, VERBOSE) { "searchHistory1: Recovered previous ID via address: $it" } }
|
||||
|
||||
if (recognizedDevice == null) {
|
||||
val currentMarkers = message.getApplePodsMarkings()
|
||||
recognizedDevice = knownDevices.values
|
||||
.firstOrNull { it.lastMessage.getApplePodsMarkings() == currentMarkers }
|
||||
?.also { log(tag) { "searchHistory1: Close match based on similarity: $currentMarkers" } }
|
||||
}
|
||||
|
||||
if (recognizedDevice == null) {
|
||||
log(tag, WARN) { "searchHistory1: Didn't recognize: $message" }
|
||||
}
|
||||
|
||||
return recognizedDevice
|
||||
}
|
||||
|
||||
fun updateHistory(device: PodType) {
|
||||
val existing = knownDevices[device.identifier]
|
||||
|
||||
knownDevices[device.identifier] = when {
|
||||
existing != null -> {
|
||||
val history = existing.history.plus(device)
|
||||
existing.copy(
|
||||
seenCounter = existing.seenCounter + 1,
|
||||
history = history,
|
||||
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
log(tag) { "searchHistory1: Creating new history for $device" }
|
||||
val history = listOf(device)
|
||||
KnownDevice(
|
||||
id = device.identifier,
|
||||
seenFirstAt = device.seenFirstAt,
|
||||
seenCounter = 1,
|
||||
history = history,
|
||||
lastCaseBattery = history.determineLatestCaseBattery()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ModelInfo(
|
||||
val full: UShort,
|
||||
val dirty: UByte,
|
||||
)
|
||||
|
||||
fun ProximityPairing.Message.getModelInfo(): ModelInfo = ModelInfo(
|
||||
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
|
||||
dirty = data[1]
|
||||
)
|
||||
|
||||
abstract fun isResponsible(message: ProximityPairing.Message): Boolean
|
||||
|
||||
abstract fun create(
|
||||
scanResult: BleScanResult,
|
||||
message: ProximityPairing.Message,
|
||||
): ApplePods
|
||||
}
|
||||
@@ -4,14 +4,18 @@ import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.*
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.DualPodDevice.Pod
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasDualMicrophone
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
|
||||
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
|
||||
HasDualMicrophone, HasAppleColor {
|
||||
|
||||
val primaryPod: Pod
|
||||
get() = when (rawStatus.isBitSet(5)) {
|
||||
get() = when (pubStatus.isBitSet(5)) {
|
||||
true -> Pod.LEFT
|
||||
false -> Pod.RIGHT
|
||||
}
|
||||
@@ -21,13 +25,17 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
|
||||
* If the right pod is the primary, the values are flipped.
|
||||
*/
|
||||
val areValuesFlipped: Boolean
|
||||
get() = !rawStatus.isBitSet(5)
|
||||
get() = !pubStatus.isBitSet(5)
|
||||
|
||||
override val batteryLeftPodPercent: Float?
|
||||
get() {
|
||||
payload.private?.asBatteryState(if (areValuesFlipped) 2 else 1)?.let {
|
||||
return it.level
|
||||
}
|
||||
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.upperNibble.toInt()
|
||||
false -> rawPodsBattery.lowerNibble.toInt()
|
||||
true -> pubPodsBattery.upperNibble.toInt()
|
||||
false -> pubPodsBattery.lowerNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
@@ -42,9 +50,13 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
|
||||
|
||||
override val batteryRightPodPercent: Float?
|
||||
get() {
|
||||
payload.private?.asBatteryState(if (areValuesFlipped) 1 else 2)?.let {
|
||||
return it.level
|
||||
}
|
||||
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.lowerNibble.toInt()
|
||||
false -> rawPodsBattery.upperNibble.toInt()
|
||||
true -> pubPodsBattery.lowerNibble.toInt()
|
||||
false -> pubPodsBattery.upperNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
@@ -58,24 +70,24 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
|
||||
}
|
||||
|
||||
val isThisPodInThecase: Boolean
|
||||
get() = rawStatus.isBitSet(6)
|
||||
get() = pubStatus.isBitSet(6)
|
||||
|
||||
val isOnePodInCase: Boolean
|
||||
get() = rawStatus.isBitSet(4)
|
||||
get() = pubStatus.isBitSet(4)
|
||||
|
||||
val areBothPodsInCase: Boolean
|
||||
get() = rawStatus.isBitSet(2)
|
||||
get() = pubStatus.isBitSet(2)
|
||||
|
||||
override val isLeftPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(3)
|
||||
false -> rawStatus.isBitSet(1)
|
||||
true -> pubStatus.isBitSet(3)
|
||||
false -> pubStatus.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(1)
|
||||
false -> rawStatus.isBitSet(3)
|
||||
true -> pubStatus.isBitSet(1)
|
||||
false -> pubStatus.isBitSet(3)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,45 +95,63 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
|
||||
* For the pod that is in the case, this is flipped again though.
|
||||
*/
|
||||
override val isLeftPodMicrophone: Boolean
|
||||
get() = rawStatus.isBitSet(5) xor isThisPodInThecase
|
||||
get() = pubStatus.isBitSet(5) xor isThisPodInThecase
|
||||
|
||||
/**
|
||||
* The data flip bit is UNset if the right pod is primary.
|
||||
* For the pod that is in the case, this is flipped again though.
|
||||
*/
|
||||
override val isRightPodMicrophone: Boolean
|
||||
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
|
||||
get() = !pubStatus.isBitSet(5) xor isThisPodInThecase
|
||||
|
||||
override val isLeftPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(0)
|
||||
true -> rawFlags.isBitSet(1)
|
||||
get() {
|
||||
payload.private?.asBatteryState(if (areValuesFlipped) 2 else 1)?.let {
|
||||
return it.isCharging
|
||||
}
|
||||
return when (areValuesFlipped) {
|
||||
false -> pubFlags.isBitSet(0)
|
||||
true -> pubFlags.isBitSet(1)
|
||||
}
|
||||
}
|
||||
|
||||
override val isRightPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(1)
|
||||
true -> rawFlags.isBitSet(0)
|
||||
get() {
|
||||
payload.private?.asBatteryState(if (areValuesFlipped) 1 else 2)?.let {
|
||||
return it.isCharging
|
||||
}
|
||||
return when (areValuesFlipped) {
|
||||
false -> pubFlags.isBitSet(1)
|
||||
true -> pubFlags.isBitSet(0)
|
||||
}
|
||||
}
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = when (val value = rawCaseBattery.toInt()) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Case: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
get() {
|
||||
payload.private?.asBatteryState(3)?.let { return it.level }
|
||||
|
||||
return when (val value = pubCaseBattery.toInt()) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Case: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val isCaseCharging: Boolean
|
||||
get() = rawFlags.isBitSet(2)
|
||||
get() {
|
||||
payload.private?.asBatteryState(3)?.let { return it.isCharging }
|
||||
|
||||
return pubFlags.isBitSet(2)
|
||||
}
|
||||
|
||||
val caseLidState: LidState
|
||||
get() {
|
||||
val rawstate = rawCaseLidState
|
||||
return LidState.values().firstOrNull { it.rawRange.contains(rawstate.toInt()) } ?: LidState.UNKNOWN
|
||||
val rawstate = pubCaseLidState
|
||||
return LidState.entries.firstOrNull { it.rawRange.contains(rawstate.toInt()) } ?: LidState.UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
|
||||
abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualApplePods>(tag) {
|
||||
|
||||
fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings(
|
||||
leftPodBattery = batteryLeftPodPercent,
|
||||
rightPodBattery = batteryRightPodPercent,
|
||||
microPhoneLeft = isLeftPodMicrophone,
|
||||
microPhoneRight = isRightPodMicrophone,
|
||||
chargingLeft = isLeftPodCharging,
|
||||
chargingRight = isRightPodCharging,
|
||||
color = rawDeviceColor,
|
||||
model = model
|
||||
)
|
||||
|
||||
/**
|
||||
* Split pods, one in case, one not.
|
||||
*/
|
||||
data class SplitPodsMarkings(
|
||||
val leftPodBattery: Float?,
|
||||
val rightPodBattery: Float?,
|
||||
val microPhoneLeft: Boolean,
|
||||
val microPhoneRight: Boolean,
|
||||
val chargingLeft: Boolean,
|
||||
val chargingRight: Boolean,
|
||||
val color: UByte,
|
||||
val model: PodDevice.Model,
|
||||
)
|
||||
|
||||
private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualApplePods): Collection<KnownDevice> {
|
||||
val target = device.getCaseMatchMarkings()
|
||||
|
||||
return filter { known ->
|
||||
known.history
|
||||
.filterIsInstance<DualApplePods>()
|
||||
.any { it.getCaseMatchMarkings() == target }
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchHistory(current: DualApplePods): KnownDevice? {
|
||||
val basicResult = super.searchHistory(current)
|
||||
|
||||
val caseIgnored = knownDevices.values.findSplitPodsMatch(current)
|
||||
|
||||
log(tag, DEBUG) { "searchHistory2: Case ignored matches(${caseIgnored.size}): $caseIgnored" }
|
||||
|
||||
return when (caseIgnored.size) {
|
||||
0 -> basicResult
|
||||
1 -> caseIgnored.single()
|
||||
else -> {
|
||||
log(tag) { "searchHistory2: More than one result when ignoring case markers." }
|
||||
val oldest = caseIgnored.maxByOrNull { it.history.size } ?: return null
|
||||
|
||||
caseIgnored.minus(oldest).forEach {
|
||||
log(tag) { "searchHistory2: Removing outlier: $it" }
|
||||
knownDevices.remove(it.id)
|
||||
}
|
||||
|
||||
oldest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import eu.darken.capod.pods.core.HasPodStyle
|
||||
interface HasAppleColor : ApplePods, HasPodStyle {
|
||||
|
||||
override val podStyle: HasPodStyle.PodStyle
|
||||
get() = DeviceColor.values()
|
||||
.firstOrNull { it.raw == rawDeviceColor }
|
||||
get() = DeviceColor.entries
|
||||
.firstOrNull { it.raw == pubDeviceColor }
|
||||
?: DeviceColor.UNKNOWN
|
||||
|
||||
enum class DeviceColor(val raw: UByte?) : HasPodStyle.PodStyle {
|
||||
|
||||
@@ -10,13 +10,18 @@ import eu.darken.capod.pods.core.SinglePodDevice
|
||||
interface SingleApplePods : ApplePods, SinglePodDevice, HasAppleColor {
|
||||
|
||||
override val batteryHeadsetPercent: Float?
|
||||
get() = when (val value = rawPodsBattery.lowerNibble.toInt()) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Headset above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
(value / 10f)
|
||||
get() {
|
||||
payload.private?.asBatteryState(1)?.let {
|
||||
return it.level
|
||||
}
|
||||
return when (val value = pubPodsBattery.lowerNibble.toInt()) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Headset above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
(value / 10f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
abstract class SingleApplePodsFactory(private val tag: String) : ApplePodsFactory<SingleApplePods>(tag)
|
||||
@@ -4,19 +4,23 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsGen1 constructor(
|
||||
data class AirPodsGen1(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
@@ -34,19 +38,28 @@ data class AirPodsGen1 constructor(
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = AirPodsGen1(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = AirPodsGen1(
|
||||
scanResult = scanResult,
|
||||
payload = payload,
|
||||
flags = flags,
|
||||
)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -4,19 +4,23 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsGen2 constructor(
|
||||
data class AirPodsGen2(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
@@ -34,18 +38,28 @@ data class AirPodsGen2 constructor(
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||
|
||||
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = AirPodsGen2(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = AirPodsGen2(
|
||||
scanResult = scanResult,
|
||||
payload = payload,
|
||||
flags = flags,
|
||||
)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -4,19 +4,23 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsGen3 constructor(
|
||||
data class AirPodsGen3(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
@@ -34,18 +38,24 @@ data class AirPodsGen3 constructor(
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||
|
||||
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = AirPodsGen3(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = AirPodsGen3(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsGen4(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
private val cachedCaseState: DualApplePods.LidState? = null
|
||||
) : DualApplePods, HasStateDetectionAirPods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN4
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val caseLidState: DualApplePods.LidState
|
||||
get() = cachedCaseState ?: super.caseLidState
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = AirPodsGen4(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenLastAt = scanResult.receivedAt,
|
||||
seenCounter = result.seenCounter,
|
||||
reliability = result.reliability,
|
||||
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||
rssiAverage = result.rssiSmoothed(basic.rssi),
|
||||
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x1920.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Gen4")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsGen4Anc(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
private val cachedCaseState: DualApplePods.LidState? = null
|
||||
) : DualApplePods, HasStateDetectionAirPods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN4_ANC
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val caseLidState: DualApplePods.LidState
|
||||
get() = cachedCaseState ?: super.caseLidState
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = AirPodsGen4Anc(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenLastAt = scanResult.receivedAt,
|
||||
seenCounter = result.seenCounter,
|
||||
reliability = result.reliability,
|
||||
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||
rssiAverage = result.rssiSmoothed(basic.rssi),
|
||||
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x1B20.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Gen4-ANC")
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,17 @@ package eu.darken.capod.pods.core.apple.airpods
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.pods.core.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -19,37 +23,46 @@ data class AirPodsMax(
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods, HasEarDetection, HasAppleColor {
|
||||
) : SingleApplePods, HasEarDetection, HasChargeDetection, HasAppleColor {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<SingleApplePods>.rssi
|
||||
|
||||
val isHeadphonesBeingWorn: Boolean
|
||||
get() = rawStatus.isBitSet(1)
|
||||
|
||||
val isHeadsetBeingCharged: Boolean
|
||||
get() = rawFlags.isBitSet(0)
|
||||
|
||||
override val isBeingWorn: Boolean
|
||||
get() = isHeadphonesBeingWorn
|
||||
|
||||
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
override val isHeadsetBeingCharged: Boolean
|
||||
get() {
|
||||
payload.private?.asBatteryState(1)?.let {
|
||||
return it.isCharging
|
||||
}
|
||||
return pubFlags.isBitSet(0)
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = AirPodsMax(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override val isBeingWorn: Boolean
|
||||
get() = pubStatus.isBitSet(5)
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = AirPodsMax(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
@@ -65,7 +78,7 @@ data class AirPodsMax(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE_DIRTY = 10.toUByte()
|
||||
private val DEVICE_CODE = 0x0A20.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Max")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.pods.core.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsMaxUsbc(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods, HasEarDetection, HasChargeDetection, HasAppleColor {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX_USBC
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<SingleApplePods>.rssi
|
||||
|
||||
override val isHeadsetBeingCharged: Boolean
|
||||
get() {
|
||||
payload.private?.asBatteryState(1)?.let {
|
||||
return it.isCharging
|
||||
}
|
||||
return pubFlags.isBitSet(0)
|
||||
}
|
||||
|
||||
override val isBeingWorn: Boolean
|
||||
get() = pubStatus.isBitSet(5)
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = AirPodsMaxUsbc(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenLastAt = scanResult.receivedAt,
|
||||
seenCounter = result.seenCounter,
|
||||
reliability = result.reliability,
|
||||
rssiAverage = result.rssiSmoothed(basic.rssi),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x1F20.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Max", "USBC")
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,13 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -19,7 +22,8 @@ data class AirPodsPro(
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
@@ -53,18 +57,28 @@ data class AirPodsPro(
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||
|
||||
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = AirPodsPro(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = AirPodsPro(
|
||||
scanResult = scanResult,
|
||||
payload = payload,
|
||||
flags = flags,
|
||||
)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
@@ -79,7 +93,6 @@ data class AirPodsPro(
|
||||
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -6,10 +6,13 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -19,7 +22,8 @@ data class AirPodsPro2(
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
@@ -53,18 +57,24 @@ data class AirPodsPro2(
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<HasStateDetectionAirPods>.rssi
|
||||
|
||||
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = AirPodsPro2(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = AirPodsPro2(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.common.R
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsPro2Usbc(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
private val cachedCaseState: LidState? = null
|
||||
) : DualApplePods, HasStateDetectionAirPods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO2_USBC
|
||||
|
||||
@get:DrawableRes
|
||||
override val iconRes: Int
|
||||
get() = R.drawable.devic_airpods_pro2_both
|
||||
|
||||
@get:DrawableRes
|
||||
override val caseIcon: Int
|
||||
get() = R.drawable.devic_airpods_pro2_case
|
||||
|
||||
@get:DrawableRes
|
||||
override val leftPodIcon: Int
|
||||
get() = R.drawable.devic_airpods_pro2_left
|
||||
|
||||
@get:DrawableRes
|
||||
override val rightPodIcon: Int
|
||||
get() = R.drawable.devic_airpods_pro2_right
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val caseLidState: LidState
|
||||
get() = cachedCaseState ?: super.caseLidState
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<HasStateDetectionAirPods>.rssi
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = AirPodsPro2Usbc(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenLastAt = scanResult.receivedAt,
|
||||
seenCounter = result.seenCounter,
|
||||
reliability = result.reliability,
|
||||
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||
rssiAverage = result.rssiSmoothed(basic.rssi),
|
||||
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x2420.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro2", "USBC")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.common.R
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsPro3(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
private val cachedCaseState: LidState? = null
|
||||
) : DualApplePods, HasStateDetectionAirPods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO3
|
||||
|
||||
@get:DrawableRes
|
||||
override val iconRes: Int
|
||||
get() = R.drawable.devic_airpods_pro2_both
|
||||
|
||||
@get:DrawableRes
|
||||
override val caseIcon: Int
|
||||
get() = R.drawable.devic_airpods_pro2_case
|
||||
|
||||
@get:DrawableRes
|
||||
override val leftPodIcon: Int
|
||||
get() = R.drawable.devic_airpods_pro2_left
|
||||
|
||||
@get:DrawableRes
|
||||
override val rightPodIcon: Int
|
||||
get() = R.drawable.devic_airpods_pro2_right
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val caseLidState: LidState
|
||||
get() = cachedCaseState ?: super.caseLidState
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<HasStateDetectionAirPods>.rssi
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = AirPodsPro3(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenLastAt = scanResult.receivedAt,
|
||||
seenCounter = result.seenCounter,
|
||||
reliability = result.reliability,
|
||||
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||
rssiAverage = result.rssiSmoothed(basic.rssi),
|
||||
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Device code for AirPods Pro 3
|
||||
// Following the pattern of other AirPods models (ends with 0x20 for Apple vendor)
|
||||
private val DEVICE_CODE = 0x2720.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro3")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@ import eu.darken.capod.pods.core.apple.ApplePods
|
||||
interface HasStateDetectionAirPods : HasStateDetection, ApplePods {
|
||||
|
||||
override val state: ConnectionState
|
||||
get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN
|
||||
get() = ConnectionState.entries.firstOrNull { pubSuffix == it.raw } ?: ConnectionState.UNKNOWN
|
||||
|
||||
enum class ConnectionState(val raw: UByte?, @StringRes val labelRes: Int) : HasStateDetection.State {
|
||||
DISCONNECTED(0x00, R.string.pods_connection_state_disconnected_label),
|
||||
|
||||
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -16,7 +19,8 @@ data class BeatsFitPro(
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
@@ -34,18 +38,24 @@ data class BeatsFitPro(
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = BeatsFitPro(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = BeatsFitPro(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -16,7 +19,8 @@ data class BeatsFlex(
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods {
|
||||
@@ -26,18 +30,24 @@ data class BeatsFlex(
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = BeatsFlex(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = BeatsFlex(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -16,7 +19,8 @@ data class BeatsSolo3(
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods {
|
||||
@@ -25,18 +29,24 @@ data class BeatsSolo3(
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = BeatsSolo3(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = BeatsSolo3(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -16,25 +19,32 @@ data class BeatsStudio3(
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3
|
||||
|
||||
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = BeatsStudio3(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = BeatsStudio3(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -16,7 +19,8 @@ data class BeatsX(
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods {
|
||||
@@ -26,18 +30,24 @@ data class BeatsX(
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = BeatsX(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = BeatsX(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -16,7 +19,8 @@ data class PowerBeats3(
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods {
|
||||
@@ -26,18 +30,24 @@ data class PowerBeats3(
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = PowerBeats3(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = PowerBeats3(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PowerBeats4(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods, HasStateDetectionAirPods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_4
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<SingleApplePods>.rssi
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = PowerBeats4(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenLastAt = scanResult.receivedAt,
|
||||
seenCounter = result.seenCounter,
|
||||
reliability = result.reliability,
|
||||
rssiAverage = result.rssiSmoothed(basic.rssi),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x0D20.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Beats", "PowerBeats", "4")
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -16,7 +19,8 @@ data class PowerBeatsPro(
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
@@ -34,18 +38,24 @@ data class PowerBeatsPro(
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = PowerBeatsPro(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = PowerBeatsPro(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
@@ -64,7 +74,7 @@ data class PowerBeatsPro(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE_DIRTY = 11.toUByte()
|
||||
private val DEVICE_CODE = 0x0B20.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Beats", "PowerBeats", "Pro")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PowerBeatsPro2(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
private val cachedCaseState: DualApplePods.LidState? = null
|
||||
) : DualApplePods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO2
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val caseLidState: DualApplePods.LidState
|
||||
get() = cachedCaseState ?: super.caseLidState
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = PowerBeatsPro2(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenLastAt = scanResult.receivedAt,
|
||||
seenCounter = result.seenCounter,
|
||||
reliability = result.reliability,
|
||||
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||
rssiAverage = result.rssiSmoothed(basic.rssi),
|
||||
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x1D20.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Beats", "PowerBeats", "Pro2")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package eu.darken.capod.pods.core.apple.history
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.collections.median
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlin.math.max
|
||||
|
||||
data class KnownDevice(
|
||||
val id: PodDevice.Id,
|
||||
val seenFirstAt: Instant,
|
||||
val seenCounter: Int,
|
||||
val history: List<ApplePods>,
|
||||
val lastCaseBattery: Float?,
|
||||
) {
|
||||
val lastPayload: ProximityPayload
|
||||
get() = history.last().payload
|
||||
|
||||
val lastAddress: BluetoothAddress
|
||||
get() = history.last().address
|
||||
|
||||
val reliability: Float
|
||||
get() {
|
||||
if (history.size < 2) return 0f
|
||||
|
||||
val now = Instant.now()
|
||||
|
||||
val pingInterval = List(history.dropLast(1).size) { index ->
|
||||
Duration.between(history[index].seenLastAt, history[index + 1].seenLastAt).toMillis()
|
||||
}.map { it.toInt() }.median()
|
||||
|
||||
val expectedPings: Float = LOOKBACK.toMillis() / pingInterval.toFloat()
|
||||
|
||||
val recentPings = history.filter {
|
||||
Duration.between(it.seenLastAt, now).toMillis() < LOOKBACK.toMillis() + pingInterval
|
||||
}.size
|
||||
|
||||
val reliability: Float = (recentPings / expectedPings)
|
||||
// log("#####") { "interval=$pingInterval, expectedPerMinute=$expectedPings, count=$recentPings" }
|
||||
|
||||
return max(0.0f, reliability).coerceAtMost(1f)
|
||||
}
|
||||
|
||||
fun rssiSmoothed(latest: Int): Int {
|
||||
val now = Instant.now()
|
||||
return history
|
||||
.filter { Duration.between(it.seenLastAt, now) < LOOKBACK }
|
||||
.map { it.rssi }
|
||||
.plus(latest)
|
||||
.median()
|
||||
}
|
||||
|
||||
fun isOlderThan(age: Duration): Boolean {
|
||||
val now = Instant.now()
|
||||
return Duration.between(history.last().seenLastAt, now) > age
|
||||
}
|
||||
|
||||
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
|
||||
|
||||
companion object {
|
||||
// 30 seconds at fastest interval
|
||||
const val MAX_HISTORY = 60
|
||||
val LOOKBACK = Duration.ofSeconds(30)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package eu.darken.capod.pods.core.apple.history
|
||||
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import eu.darken.capod.pods.core.apple.protocol.RPAChecker
|
||||
import java.time.Duration
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PodHistoryRepo @Inject constructor(
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val rpaChecker: RPAChecker,
|
||||
) {
|
||||
|
||||
private val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
|
||||
|
||||
data class Identifier(
|
||||
val device: UShort,
|
||||
val podBatteryData: Set<UShort>,
|
||||
val caseBatteryData: UShort,
|
||||
val deviceColor: UByte,
|
||||
)
|
||||
|
||||
private fun ProximityPayload.getFuzzyIdentifier(): Identifier = Identifier(
|
||||
device = (((public.data[1].toInt() and 255) shl 8) or (public.data[2].toInt() and 255)).toUShort(),
|
||||
// Make comparison order independent
|
||||
podBatteryData = setOf(public.data[4].upperNibble, public.data[4].lowerNibble),
|
||||
caseBatteryData = public.data[5].lowerNibble,
|
||||
deviceColor = public.data[7]
|
||||
)
|
||||
|
||||
fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings(
|
||||
leftPodBattery = batteryLeftPodPercent,
|
||||
rightPodBattery = batteryRightPodPercent,
|
||||
microPhoneLeft = isLeftPodMicrophone,
|
||||
microPhoneRight = isRightPodMicrophone,
|
||||
chargingLeft = isLeftPodCharging,
|
||||
chargingRight = isRightPodCharging,
|
||||
color = pubDeviceColor,
|
||||
model = model
|
||||
)
|
||||
|
||||
/**
|
||||
* Split pods, one in case, one not.
|
||||
*/
|
||||
data class SplitPodsMarkings(
|
||||
val leftPodBattery: Float?,
|
||||
val rightPodBattery: Float?,
|
||||
val microPhoneLeft: Boolean,
|
||||
val microPhoneRight: Boolean,
|
||||
val chargingLeft: Boolean,
|
||||
val chargingRight: Boolean,
|
||||
val color: UByte,
|
||||
val model: PodDevice.Model,
|
||||
)
|
||||
|
||||
fun search(current: ApplePods): KnownDevice? {
|
||||
val basicResult = baseSearch(current)
|
||||
|
||||
val caseIgnored = if (current is DualApplePods) {
|
||||
val target = current.getCaseMatchMarkings()
|
||||
knownDevices.values.filter { known ->
|
||||
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target }
|
||||
}
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
log(TAG, DEBUG) { "search2: Case ignored matches(${caseIgnored.size}): $caseIgnored" }
|
||||
|
||||
return when (caseIgnored.size) {
|
||||
0 -> basicResult
|
||||
1 -> caseIgnored.single()
|
||||
else -> {
|
||||
log(TAG) { "search2: More than one result when ignoring case markers." }
|
||||
val oldest = caseIgnored.maxByOrNull { it.history.size } ?: return null
|
||||
|
||||
caseIgnored.minus(oldest).forEach {
|
||||
log(TAG) { "search2: Removing outlier: $it" }
|
||||
knownDevices.remove(it.id)
|
||||
}
|
||||
|
||||
oldest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun baseSearch(current: ApplePods): KnownDevice? {
|
||||
val scanResult = current.scanResult
|
||||
val payload = current.payload
|
||||
|
||||
// Timeout
|
||||
knownDevices.values.toList()
|
||||
.filter { it.isOlderThan(Duration.ofSeconds(30)) }
|
||||
.forEach { knownDevice ->
|
||||
log(TAG, VERBOSE) { "search1: Removing stale known device: $knownDevice" }
|
||||
knownDevices.remove(knownDevice.id)
|
||||
}
|
||||
|
||||
// Trim history
|
||||
knownDevices.values
|
||||
.filter { it.history.size > KnownDevice.MAX_HISTORY }
|
||||
.toList()
|
||||
.forEach { knownDevices[it.id] = it.copy(history = it.history.takeLast(KnownDevice.MAX_HISTORY)) }
|
||||
|
||||
var recognizedDevice: KnownDevice? = knownDevices.values
|
||||
.firstOrNull { it.lastAddress == scanResult.address }
|
||||
?.also { log(TAG, VERBOSE) { "search1: Recovered previous ID via address: $it" } }
|
||||
|
||||
if (recognizedDevice == null) {
|
||||
generalSettings.mainDeviceIdentityKey.value?.let { key ->
|
||||
if (!rpaChecker.verify(current.address, key)) {
|
||||
log(TAG, VERBOSE) { "search1: Current device does not match IRK" }
|
||||
return@let
|
||||
}
|
||||
|
||||
recognizedDevice = knownDevices.values
|
||||
.firstOrNull { rpaChecker.verify(it.lastAddress, key) }
|
||||
.also { log(TAG, VERBOSE) { "search1: Recovered previous ID via IRK: $it" } }
|
||||
}
|
||||
}
|
||||
|
||||
if (recognizedDevice == null) {
|
||||
val currentMarkers = payload.getFuzzyIdentifier()
|
||||
recognizedDevice = knownDevices.values
|
||||
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers }
|
||||
?.also { log(TAG) { "search1: Close match based on similarity: $currentMarkers" } }
|
||||
}
|
||||
|
||||
if (recognizedDevice == null) log(TAG, WARN) { "search1: Didn't recognize: $current" }
|
||||
|
||||
return recognizedDevice
|
||||
}
|
||||
|
||||
fun updateHistory(device: ApplePods) {
|
||||
val existing = knownDevices[device.identifier]
|
||||
|
||||
fun Collection<ApplePods>.determineLatestCaseBattery(): Float? = this
|
||||
.filterIsInstance<HasCase>()
|
||||
.mapNotNull { it.batteryCasePercent }
|
||||
.lastOrNull()
|
||||
|
||||
knownDevices[device.identifier] = if (existing != null) {
|
||||
val history = existing.history.plus(device)
|
||||
existing.copy(
|
||||
seenCounter = existing.seenCounter + 1,
|
||||
history = history,
|
||||
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery
|
||||
)
|
||||
} else {
|
||||
log(TAG) { "Creating new history for $device" }
|
||||
val history = listOf(device)
|
||||
KnownDevice(
|
||||
id = device.identifier,
|
||||
seenFirstAt = device.seenFirstAt,
|
||||
seenCounter = 1,
|
||||
history = history,
|
||||
lastCaseBattery = history.determineLatestCaseBattery()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Pod", "Apple", "Factory", "History")
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
package eu.darken.capod.pods.core.apple.misc
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.*
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -18,118 +20,46 @@ import javax.inject.Inject
|
||||
* Similar data structure but a lot of placeholder values or hardcoded values
|
||||
* Marketed as TWS i99999
|
||||
*/
|
||||
data class FakeAirPodsGen1 constructor(
|
||||
data class FakeAirPodsGen1(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
|
||||
) : DualApplePods, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN1
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<ApplePods>.rssi
|
||||
|
||||
/**
|
||||
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
|
||||
* If the right pod is the primary, the values are flipped.
|
||||
*/
|
||||
val areValuesFlipped: Boolean
|
||||
get() = !rawStatus.isBitSet(5)
|
||||
|
||||
override val batteryLeftPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.upperNibble.toInt()
|
||||
false -> rawPodsBattery.lowerNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Left pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
(value / 10f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val batteryRightPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.lowerNibble.toInt()
|
||||
false -> rawPodsBattery.upperNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Right pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val isThisPodInThecase: Boolean
|
||||
get() = rawStatus.isBitSet(6)
|
||||
|
||||
override val isLeftPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(3)
|
||||
false -> rawStatus.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(1)
|
||||
false -> rawStatus.isBitSet(3)
|
||||
}
|
||||
|
||||
override val isLeftPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(0)
|
||||
true -> rawFlags.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(1)
|
||||
true -> rawFlags.isBitSet(0)
|
||||
}
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = when (val value = rawCaseBattery.toInt()) {
|
||||
15 -> cachedBatteryPercentage
|
||||
else -> if (value > 10) {
|
||||
log { "Case: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val isCaseCharging: Boolean
|
||||
get() = rawFlags.isBitSet(2)
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||
|
||||
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen1>(TAG) {
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
|
||||
getModelInfo().full == DEVICE_CODE && length == 19
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = FakeAirPodsGen1(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = FakeAirPodsGen1(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -1,133 +1,63 @@
|
||||
package eu.darken.capod.pods.core.apple.misc
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.*
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Similar data structure but a lot of placeholder values or hardcoded values
|
||||
*/
|
||||
data class FakeAirPodsGen2 constructor(
|
||||
data class FakeAirPodsGen2(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
|
||||
) : DualApplePods, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN2
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<ApplePods>.rssi
|
||||
|
||||
/**
|
||||
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
|
||||
* If the right pod is the primary, the values are flipped.
|
||||
*/
|
||||
val areValuesFlipped: Boolean
|
||||
get() = !rawStatus.isBitSet(5)
|
||||
|
||||
override val batteryLeftPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.upperNibble.toInt()
|
||||
false -> rawPodsBattery.lowerNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Left pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
(value / 10f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val batteryRightPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.lowerNibble.toInt()
|
||||
false -> rawPodsBattery.upperNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Right pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val isThisPodInThecase: Boolean
|
||||
get() = rawStatus.isBitSet(6)
|
||||
|
||||
override val isLeftPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(3)
|
||||
false -> rawStatus.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(1)
|
||||
false -> rawStatus.isBitSet(3)
|
||||
}
|
||||
|
||||
override val isLeftPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(0)
|
||||
true -> rawFlags.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(1)
|
||||
true -> rawFlags.isBitSet(0)
|
||||
}
|
||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = when (val value = rawCaseBattery.toInt()) {
|
||||
15 -> cachedBatteryPercentage
|
||||
else -> if (value > 10) {
|
||||
log { "Case: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val isCaseCharging: Boolean
|
||||
get() = rawFlags.isBitSet(2)
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen2>(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
|
||||
getModelInfo().full == DEVICE_CODE && length == 19
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = FakeAirPodsGen2(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = FakeAirPodsGen2(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
package eu.darken.capod.pods.core.apple.misc
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.*
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -18,118 +20,46 @@ import javax.inject.Inject
|
||||
* Shorter data structure.
|
||||
* https://discord.com/channels/548521543039189022/927235844127993866/1054404630118924308
|
||||
*/
|
||||
data class FakeAirPodsGen3 constructor(
|
||||
data class FakeAirPodsGen3(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
|
||||
) : DualApplePods, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN3
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<ApplePods>.rssi
|
||||
|
||||
/**
|
||||
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
|
||||
* If the right pod is the primary, the values are flipped.
|
||||
*/
|
||||
val areValuesFlipped: Boolean
|
||||
get() = !rawStatus.isBitSet(5)
|
||||
|
||||
override val batteryLeftPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.upperNibble.toInt()
|
||||
false -> rawPodsBattery.lowerNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Left pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
(value / 10f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val batteryRightPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.lowerNibble.toInt()
|
||||
false -> rawPodsBattery.upperNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Right pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val isLeftPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(0)
|
||||
true -> rawFlags.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(1)
|
||||
true -> rawFlags.isBitSet(0)
|
||||
}
|
||||
|
||||
private val isThisPodInThecase: Boolean
|
||||
get() = rawStatus.isBitSet(6)
|
||||
|
||||
override val isLeftPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(3)
|
||||
false -> rawStatus.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(1)
|
||||
false -> rawStatus.isBitSet(3)
|
||||
}
|
||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = when (val value = rawCaseBattery.toInt()) {
|
||||
15 -> cachedBatteryPercentage
|
||||
else -> if (value > 10) {
|
||||
log { "Case: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val isCaseCharging: Boolean
|
||||
get() = rawFlags.isBitSet(2)
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen3>(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
|
||||
getModelInfo().full == DEVICE_CODE && length == 19
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = FakeAirPodsGen3(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = FakeAirPodsGen3(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -3,15 +3,17 @@ package eu.darken.capod.pods.core.apple.misc
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.common.R
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.*
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -20,17 +22,18 @@ import javax.inject.Inject
|
||||
* Shorter data structure.
|
||||
* https://discord.com/channels/548521543039189022/927235844127993866/962068944196358234
|
||||
*/
|
||||
data class FakeAirPodsPro constructor(
|
||||
data class FakeAirPodsPro(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
) : ApplePods, DualPodDevice, HasChargeDetectionDual, HasEarDetectionDual, HasCase {
|
||||
) : DualApplePods, HasChargeDetectionDual, HasEarDetectionDual, HasCase {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO
|
||||
|
||||
@@ -51,103 +54,30 @@ data class FakeAirPodsPro constructor(
|
||||
get() = R.drawable.devic_airpods_pro2_right
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<ApplePods>.rssi
|
||||
|
||||
/**
|
||||
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
|
||||
* If the right pod is the primary, the values are flipped.
|
||||
*/
|
||||
val areValuesFlipped: Boolean
|
||||
get() = !rawStatus.isBitSet(5)
|
||||
|
||||
override val batteryLeftPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.upperNibble.toInt()
|
||||
false -> rawPodsBattery.lowerNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Left pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
(value / 10f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val batteryRightPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.lowerNibble.toInt()
|
||||
false -> rawPodsBattery.upperNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Right pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val isLeftPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(0)
|
||||
true -> rawFlags.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(1)
|
||||
true -> rawFlags.isBitSet(0)
|
||||
}
|
||||
|
||||
private val isThisPodInThecase: Boolean
|
||||
get() = rawStatus.isBitSet(6)
|
||||
|
||||
override val isLeftPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(3)
|
||||
false -> rawStatus.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(1)
|
||||
false -> rawStatus.isBitSet(3)
|
||||
}
|
||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = when (val value = rawCaseBattery.toInt()) {
|
||||
15 -> cachedBatteryPercentage
|
||||
else -> if (value > 10) {
|
||||
log { "Case: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val isCaseCharging: Boolean
|
||||
get() = rawFlags.isBitSet(2)
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsPro>(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
|
||||
getModelInfo().full == DEVICE_CODE && length == 19
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = FakeAirPodsPro(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = FakeAirPodsPro(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
@@ -3,16 +3,19 @@ package eu.darken.capod.pods.core.apple.misc
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.common.R
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.*
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -20,18 +23,19 @@ import javax.inject.Inject
|
||||
* AirPods Pro2 clone
|
||||
* https://discord.com/channels/548521543039189022/927235844127993866/1060911213539774504
|
||||
*/
|
||||
data class FakeAirPodsPro2 constructor(
|
||||
data class FakeAirPodsPro2(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
private val cachedCaseState: DualApplePods.LidState? = null,
|
||||
) : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase {
|
||||
private val cachedCaseState: LidState? = null,
|
||||
) : DualApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO2
|
||||
|
||||
@@ -52,99 +56,32 @@ data class FakeAirPodsPro2 constructor(
|
||||
get() = R.drawable.devic_airpods_pro2_right
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: scanResult.rssi
|
||||
|
||||
private val areValuesFlipped: Boolean
|
||||
get() = !rawStatus.isBitSet(5)
|
||||
|
||||
override val batteryLeftPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.upperNibble.toInt()
|
||||
false -> rawPodsBattery.lowerNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Left pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
(value / 10f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val batteryRightPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.lowerNibble.toInt()
|
||||
false -> rawPodsBattery.upperNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Right pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val isThisPodInThecase: Boolean
|
||||
get() = rawStatus.isBitSet(6)
|
||||
|
||||
override val isLeftPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(3)
|
||||
false -> rawStatus.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(1)
|
||||
false -> rawStatus.isBitSet(3)
|
||||
}
|
||||
|
||||
override val isLeftPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(0)
|
||||
true -> rawFlags.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(1)
|
||||
true -> rawFlags.isBitSet(0)
|
||||
}
|
||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||
override val caseLidState: LidState
|
||||
get() = cachedCaseState ?: super.caseLidState
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = when (val value = rawCaseBattery.toInt()) {
|
||||
15 -> cachedBatteryPercentage
|
||||
else -> if (value > 10) {
|
||||
log { "Case: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val isCaseCharging: Boolean
|
||||
get() = rawFlags.isBitSet(2)
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
|
||||
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsPro2>(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
|
||||
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
|
||||
getModelInfo().full == DEVICE_CODE && length == 19
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = FakeAirPodsPro2(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags
|
||||
): ApplePods {
|
||||
var basic = FakeAirPodsPro2(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
+14
-8
@@ -7,7 +7,9 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -17,7 +19,8 @@ data class UnknownAppleDevice(
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val payload: ProximityPayload,
|
||||
override val flags: ApplePods.Flags,
|
||||
override val reliability: Float = 0f,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : ApplePods {
|
||||
@@ -29,18 +32,21 @@ data class UnknownAppleDevice(
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : ApplePodsFactory<ApplePods>(TAG) {
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = true
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
) : ApplePodsFactory {
|
||||
override fun isResponsible(message: ProximityMessage): Boolean = true
|
||||
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
message: ProximityPairing.Message,
|
||||
payload: ProximityPayload,
|
||||
flags: ApplePods.Flags,
|
||||
): ApplePods {
|
||||
var basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
var basic = UnknownAppleDevice(scanResult = scanResult, payload = payload, flags = flags)
|
||||
val result = repo.search(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
repo.updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol
|
||||
|
||||
typealias IdentityResolvingKey = ByteArray
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol
|
||||
|
||||
typealias ProximityEncryptionKey = ByteArray
|
||||
@@ -0,0 +1,58 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol
|
||||
|
||||
import android.R.id.message
|
||||
import android.annotation.SuppressLint
|
||||
import dagger.Reusable
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
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 okio.ByteString.Companion.toByteString
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ProximityMessage(
|
||||
val type: UByte,
|
||||
val length: Int,
|
||||
val data: UByteArray
|
||||
) {
|
||||
|
||||
override fun toString(): String {
|
||||
val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) }
|
||||
return "ProximityPairing.Message(type=$type, length=$length, data=$dataHex)"
|
||||
}
|
||||
|
||||
@Reusable
|
||||
class Decrypter @Inject constructor() {
|
||||
@SuppressLint("GetInstance")
|
||||
fun decrypt(data: ByteArray, key: ProximityEncryptionKey): UByteArray? {
|
||||
val decryptedData = try {
|
||||
val cipher = Cipher.getInstance("AES/ECB/NoPadding").apply {
|
||||
val secretKey = SecretKeySpec(key, "AES")
|
||||
init(Cipher.DECRYPT_MODE, secretKey)
|
||||
}
|
||||
cipher.doFinal(data.copyOfRange(data.size - 16, data.size))
|
||||
} catch (e: Exception) {
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.ERROR
|
||||
) { "Failed to decrypt $message with ${key.toByteString()}\n${e.asLog()}" }
|
||||
null
|
||||
}
|
||||
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.VERBOSE
|
||||
) { "Decrypted $message with ${key.toByteString()} to ${decryptedData?.toByteString()}" }
|
||||
|
||||
if (decryptedData == null || decryptedData.size != 16) return null
|
||||
|
||||
return decryptedData.toUByteArray()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Pod", "Factory", "Decrypter")
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-14
@@ -8,27 +8,15 @@ import javax.inject.Inject
|
||||
|
||||
object ProximityPairing {
|
||||
|
||||
data class Message(
|
||||
val type: UByte,
|
||||
val length: Int,
|
||||
val data: UByteArray
|
||||
) {
|
||||
|
||||
override fun toString(): String {
|
||||
val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) }
|
||||
return "ProximityPairing.Message(type=$type, length=$length, data=$dataHex)"
|
||||
}
|
||||
}
|
||||
|
||||
@Reusable
|
||||
class Decoder @Inject constructor() {
|
||||
fun decode(message: ContinuityProtocol.Message): Message? {
|
||||
fun decode(message: ContinuityProtocol.Message): ProximityMessage? {
|
||||
if (message.type != CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING) {
|
||||
log { "Not a proximity pairing message: $this" }
|
||||
return null
|
||||
}
|
||||
|
||||
return Message(
|
||||
return ProximityMessage(
|
||||
type = message.type,
|
||||
length = message.length,
|
||||
data = message.data
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol
|
||||
|
||||
data class ProximityPayload(
|
||||
val public: Public,
|
||||
val private: Private?,
|
||||
) {
|
||||
|
||||
data class Public(
|
||||
val data: UByteArray,
|
||||
)
|
||||
|
||||
data class Private(
|
||||
val data: UByteArray,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
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 okio.ByteString.Companion.toByteString
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import javax.inject.Inject
|
||||
|
||||
class RPAChecker @Inject constructor() {
|
||||
|
||||
// Resolvable-Private-Address
|
||||
fun verify(address: BluetoothAddress, irk: IdentityResolvingKey): Boolean = try {
|
||||
val rpa = address.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
|
||||
val prand = rpa.copyOfRange(3, 6)
|
||||
val hash = rpa.copyOfRange(0, 3)
|
||||
val computedHash = ah(irk, prand)
|
||||
hash.contentEquals(computedHash)
|
||||
} catch (e: Exception) {
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.ERROR
|
||||
) { "Failed to verify RPA\naddress=${address}\nIRK=${irk.toByteString()}\n${e.asLog()}" }
|
||||
false
|
||||
}
|
||||
|
||||
// E function (Encryption function):
|
||||
@SuppressLint("GetInstance")
|
||||
private fun e(key: ByteArray, data: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance("AES/ECB/NoPadding").apply {
|
||||
val secretKey = SecretKeySpec(key.reversedArray(), "AES")
|
||||
init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
}
|
||||
return cipher.doFinal(data.reversedArray()).reversedArray()
|
||||
}
|
||||
|
||||
// AH function (Address Hashing function):
|
||||
private fun ah(k: ByteArray, r: ByteArray): ByteArray {
|
||||
val rPadded = ByteArray(16).apply {
|
||||
r.copyInto(this, 0, 0, 3)
|
||||
}
|
||||
return e(k, rPadded).copyOfRange(0, 3)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "PodMonitor", "RPAChecker")
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ class ReactionSettings @Inject constructor(
|
||||
false
|
||||
)
|
||||
|
||||
val showPopUpOnConnection = preferences.createFlowPreference(
|
||||
"reaction.popup.connected",
|
||||
false
|
||||
)
|
||||
|
||||
val onePodMode = preferences.createFlowPreference(
|
||||
"reaction.onepod.enabled",
|
||||
false
|
||||
@@ -58,6 +63,7 @@ class ReactionSettings @Inject constructor(
|
||||
autoConnect,
|
||||
autoConnectCondition,
|
||||
showPopUpOnCaseOpen,
|
||||
showPopUpOnConnection,
|
||||
onePodMode,
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,27 @@
|
||||
package eu.darken.capod.reaction.core.popup
|
||||
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
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
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.common.flow.withPrevious
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.reaction.core.ReactionSettings
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
@@ -19,11 +31,13 @@ import javax.inject.Singleton
|
||||
class PopUpReaction @Inject constructor(
|
||||
private val podMonitor: PodMonitor,
|
||||
private val reactionSettings: ReactionSettings,
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
) {
|
||||
|
||||
private val coolDowns = mutableMapOf<PodDevice.Id, Instant>()
|
||||
private val caseCoolDowns = mutableMapOf<PodDevice.Id, Instant>()
|
||||
|
||||
fun monitor(): Flow<Event> = reactionSettings.showPopUpOnCaseOpen.flow
|
||||
private fun monitorCase(): Flow<Event> = reactionSettings.showPopUpOnCaseOpen.flow
|
||||
.flatMapLatest { isEnabled ->
|
||||
if (isEnabled) {
|
||||
podMonitor.mainDevice.distinctUntilChangedBy { it?.rawDataHex }
|
||||
@@ -32,14 +46,14 @@ class PopUpReaction @Inject constructor(
|
||||
}
|
||||
}
|
||||
.withPrevious()
|
||||
.setupCommonEventHandlers(TAG) { "monitor" }
|
||||
.setupCommonEventHandlers(TAG) { "popUpCase" }
|
||||
.mapNotNull { (previous, current) ->
|
||||
if (previous !is DualApplePods? || current !is DualApplePods) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
log(TAG, VERBOSE) {
|
||||
val prev = previous?.rawCaseLidState?.let { String.format("%02X", it.toByte()) }
|
||||
val cur = current.rawCaseLidState.let { String.format("%02X", it.toByte()) }
|
||||
val prev = previous?.pubCaseLidState?.let { String.format("%02X", it.toByte()) }
|
||||
val cur = current.pubCaseLidState.let { String.format("%02X", it.toByte()) }
|
||||
"previous=$prev (${previous?.caseLidState}), current=$cur (${current.caseLidState})"
|
||||
}
|
||||
log(TAG, VERBOSE) { "previous-id=${previous?.identifier}, current-id=${current.identifier}" }
|
||||
@@ -54,35 +68,37 @@ class PopUpReaction @Inject constructor(
|
||||
}
|
||||
log(TAG) { "Case lid status changed for monitored device." }
|
||||
|
||||
tryPopWindow(current)
|
||||
throttleCasePopUps(current)
|
||||
}
|
||||
|
||||
private suspend fun tryPopWindow(current: DualApplePods): Event? = when {
|
||||
private fun throttleCasePopUps(current: DualApplePods): Event? = when {
|
||||
current.caseLidState == DualApplePods.LidState.OPEN -> {
|
||||
log(TAG, INFO) { "Show popup" }
|
||||
|
||||
val now = Instant.now()
|
||||
val lastShown = coolDowns[current.identifier] ?: Instant.MIN
|
||||
val lastShown = caseCoolDowns[current.identifier] ?: Instant.MIN
|
||||
val sinceLastPop = Duration.between(lastShown, now)
|
||||
log(TAG) { "Time since last popup: $sinceLastPop" }
|
||||
log(TAG) { "Time since last case popup: $sinceLastPop" }
|
||||
|
||||
if (sinceLastPop >= Duration.ofSeconds(10)) {
|
||||
coolDowns[current.identifier] = Instant.now()
|
||||
caseCoolDowns[current.identifier] = Instant.now()
|
||||
Event.PopupShow(device = current)
|
||||
} else {
|
||||
log(TAG, INFO) { "Popup is still on cooldown: $sinceLastPop" }
|
||||
log(TAG, INFO) { "Case popup is still on cooldown: $sinceLastPop" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
current.caseLidState != DualApplePods.LidState.OPEN -> {
|
||||
when (current.caseLidState) {
|
||||
DualApplePods.LidState.CLOSED -> {
|
||||
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
|
||||
coolDowns.remove(current.identifier)
|
||||
caseCoolDowns.remove(current.identifier)
|
||||
}
|
||||
|
||||
else -> {
|
||||
log(TAG, WARN) { "Lid was was not actively closed, refreshing cooldown." }
|
||||
coolDowns[current.identifier] = Instant.now()
|
||||
caseCoolDowns[current.identifier] = Instant.now()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +106,78 @@ class PopUpReaction @Inject constructor(
|
||||
|
||||
Event.PopupHide()
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
private val connectionCoolDowns = mutableMapOf<BluetoothAddress, Instant>()
|
||||
|
||||
private fun monitorConnection(): Flow<Event> = reactionSettings.showPopUpOnConnection.flow
|
||||
.flatMapLatest { isEnabled ->
|
||||
if (!isEnabled) return@flatMapLatest emptyFlow()
|
||||
|
||||
combine(
|
||||
generalSettings.mainDeviceAddress.flow,
|
||||
bluetoothManager.connectedDevices().distinctUntilChanged(),
|
||||
podMonitor.mainDevice.distinctUntilChangedBy { it?.rawDataHex },
|
||||
) { targetAddress, devices, broadcast ->
|
||||
log(TAG) { "$targetAddress $broadcast $devices " }
|
||||
val direct = devices.singleOrNull { it.address == targetAddress }.also {
|
||||
log(TAG, VERBOSE) { "Connected main device is $it" }
|
||||
}
|
||||
if (direct == null) {
|
||||
connectionCoolDowns.remove(targetAddress).also {
|
||||
if (it != null) log(TAG) { "Cleared connection cooldown for $targetAddress due to disconect" }
|
||||
}
|
||||
}
|
||||
if (direct != null && broadcast != null) direct to broadcast else null
|
||||
}
|
||||
}
|
||||
.withPrevious()
|
||||
.mapNotNull { (previouss, currents) ->
|
||||
val previousConnected = previouss?.first
|
||||
log(TAG, VERBOSE) { "previousConnected: $previousConnected" }
|
||||
val previousBroadcasted = previouss?.second
|
||||
log(TAG, VERBOSE) { "previousBroadcasted: $previousBroadcasted" }
|
||||
val currentConnected = currents?.first
|
||||
log(TAG, VERBOSE) { "currentConnected: $currentConnected" }
|
||||
val currentBroadcasted = currents?.second
|
||||
log(TAG, VERBOSE) { "currentBroadcasted: $currentBroadcasted" }
|
||||
|
||||
if (previousConnected != null && previousBroadcasted != null && currentConnected == null) {
|
||||
return@mapNotNull Event.PopupHide()
|
||||
}
|
||||
|
||||
if (currentConnected == null || currentBroadcasted == null) {
|
||||
// We need an active connection
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
val ageOfBroadcastedDevice = Duration.between(Instant.now(), currentBroadcasted.seenFirstAt)
|
||||
val ageOfConnectedDevice = Duration.between(Instant.now(), currentConnected.seenFirstAt)
|
||||
if (ageOfBroadcastedDevice > (ageOfConnectedDevice + Duration.ofSeconds(30))) {
|
||||
// This is likely a false positive, some random nearby device
|
||||
// We expect the first broadcasts to not be much older than the first connection
|
||||
log(TAG, VERBOSE) { "Current broadcasted main device is probably a false-positive" }
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
val now = Instant.now()
|
||||
val lastShown = connectionCoolDowns[currentConnected.address]
|
||||
val sinceLastPop = lastShown?.let { Duration.between(it, now) }
|
||||
log(TAG) { "Time since last connection popup: ${sinceLastPop?.seconds}s" }
|
||||
|
||||
if (lastShown == null) {
|
||||
connectionCoolDowns[currentConnected.address] = Instant.now()
|
||||
Event.PopupShow(device = currentBroadcasted)
|
||||
} else {
|
||||
log(TAG) { "Connection popup is still on cooldown: $sinceLastPop" }
|
||||
null
|
||||
}
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "popUpConnection" }
|
||||
|
||||
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection())
|
||||
|
||||
sealed class Event {
|
||||
data class PopupShow(
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M23 4.5C23 6.43 21.43 8 19.5 8S16 6.43 16 4.5 17.57 1 19.5 1 23 2.57 23 4.5M19.5 10C16.47 10 14 7.53 14 4.5C14 4 14.08 3.5 14.21 3H5C3.89 3 3 3.89 3 5V19C3 20.11 3.9 21 5 21H19C20.11 21 21 20.11 21 19V9.79C20.5 9.92 20 10 19.5 10Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.materialswitch.MaterialSwitch xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/switchWidget"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@null"
|
||||
android:clickable="false"
|
||||
android:focusable="false" />
|
||||
@@ -1,2 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"></resources>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
|
||||
<string name="app_name">CAPod</string>
|
||||
<string name="app_name_pro">CAPod Pro</string>
|
||||
<string name="app_name_foss">CAPod FOSS</string>
|
||||
|
||||
<string name="general_value_not_available_label">N.v.t.</string>
|
||||
<string name="general_error_label">Fout</string>
|
||||
<string name="general_grant_permission_action">Gee toestemming</string>
|
||||
|
||||
<string name="overview_nomaindevice_label">Geen primÃĒre toestel</string>
|
||||
<string name="overview_nomaindevice_description">Alle bespeurde toestelle is waarskynlik nie joune nie. Skakel jou toestel aan en koppel dit of pas die instellings aan.</string>
|
||||
<string name="overview_bluetooth_disabled_label">Bluetooth is gedeaktiveer</string>
|
||||
<string name="overview_bluetooth_disabled_description">Bluetooth is gedeaktiveer, aktiveer dit ;)</string>
|
||||
|
||||
<string name="permission_bluetooth_connect_label">Bluetooth-verbinding</string>
|
||||
<string name="permission_bluetooth_connect_description">Hierdie toepassing vereis die "Bluetooth-verbinding"-toestemming om met gekoppelde toestelle te kommunikeer en verbindings te inisieer.</string>
|
||||
<string name="permission_bluetooth_scan_label">Bluetooth-skandering</string>
|
||||
<string name="permission_bluetooth_scan_description">Die "Bluetooth-skandering"-toestemming laat hierdie toepassing toe om Bluetooth-data van nabygeleÃĢ toestelle soos jou AirPods te ontdek en te ontvang.</string>
|
||||
<string name="permission_bluetooth_label">Bluetooth</string>
|
||||
<string name="permission_bluetooth_description">Hierdie toepassing vereis die "Bluetooth"-toestemming om aan gekoppelde Bluetooth-toestelle te koppel.</string>
|
||||
<string name="permission_access_fine_location_label">Toegang tot presiese ligging</string>
|
||||
<string name="permission_access_fine_location_description">CAPod gebruik die "presiese ligging"-toestemming om Bluetooth Lae Energie-data te ontvang. Jou oorfone gebruik Bluetooth Lae Energie-tegnologie om hul status uit te saai. Hierdie toepassing sal NIE Bluetooth-data gebruik om jou ligging te bepaal NIE.</string>
|
||||
<string name="permission_background_location_label">Toegang tot agtergrondligging</string>
|
||||
<string name="permission_background_location_description">CAPod gebruik "toegang tot agtergrondligging" om funksies soos "Wys opspringer" en "Autokoppel" moontlik te maak terwyl die toepassing gesluit is. Toegang tot agtergrondligging laat die toepassing toe om Bluetooth Lae Energie-data te ontvang terwyl dit in die agtergrond is. Hierdie toepassing sal NIE Bluetooth-data gebruik om jou ligging te bepaal NIE.</string>
|
||||
<string name="permission_ignore_battery_optimizations_label">Deaktiveer batteryoptimalisering</string>
|
||||
<string name="permission_ignore_battery_optimizations_description">Batteryoptimalisering verhoed dat hierdie toepassing betroubaar Bluetooth-data ontvang terwyl die toepassing in die agtergrond is.</string>
|
||||
<string name="permission_required_title">Die volgende toestemming word vereis:</string>
|
||||
<string name="permission_system_alert_window_label">Stelselwaarskuwingsvenster</string>
|
||||
<string name="permission_system_alert_window_description">Laat CAPod toe om bo-oor ander toepassings te teken om die funksie "Wys PopUp" moontlik te maak.</string>
|
||||
|
||||
<string name="settings_scanner_mode_lowpower_label">Lae krag</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Gebalanseerd</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>
|
||||
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">Wanneer gesien</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">Houer is oop</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">In oor</string>
|
||||
|
||||
<string name="pods_dual_left_label">Linker pod</string>
|
||||
<string name="pods_dual_right_label">Regter pod</string>
|
||||
<string name="pods_case_label">Houer</string>
|
||||
<string name="pods_case_status_open_label">Oop</string>
|
||||
<string name="pods_case_status_closed_label">Gesluit</string>
|
||||
<string name="pods_connection_state_disconnected_label">Nie aan \'n toestel gekoppel nie</string>
|
||||
<string name="pods_connection_state_idle_label">Gekoppel aan \'n toestel, maar ledig</string>
|
||||
<string name="pods_connection_state_music_label">In musiekmodus</string>
|
||||
<string name="pods_connection_state_call_label">In oproepmodus</string>
|
||||
<string name="pods_connection_state_ringing_label">Lui</string>
|
||||
<string name="pods_connection_state_hanging_up_label">Besig om af te haak</string>
|
||||
<string name="pods_connection_state_unknown_label">Onbekende verbindingstatus</string>
|
||||
<string name="pods_unknown_raw_data_label">Rou data</string>
|
||||
<string name="pods_unknown_label">Onbekende toestel</string>
|
||||
<string name="pods_unknown_contact_dev">Hierdie is \'n onbekende toestel, maar dit gebruik \'n soortgelyke boodskapformaat. Kom ons voeg ondersteuning daarvoor by, kontak my :)</string>
|
||||
<string name="pods_none_label_short">Geen toestel</string>
|
||||
<string name="pods_charging_label">Laai tans</string>
|
||||
<string name="pods_inear_label">In oor</string>
|
||||
<string name="pods_microphone_label">Mikrofoon</string>
|
||||
<string name="pods_yours">Joune</string>
|
||||
<string name="headset_being_worn_label">Word gedra</string>
|
||||
<string name="headset_not_being_worn_label">Word nie gedra nie</string>
|
||||
<string name="pods_case_unknown_state">Onbekende status</string>
|
||||
|
||||
<string name="last_seen_x">Laas gesien: %s</string>
|
||||
<string name="first_seen_x">Eerste gesien: %s</string>
|
||||
<string name="permission_post_notifications_label">Wys kennisgewings</string>
|
||||
<string name="permission_post_notifications_description">Laat CAPod toe om kennisgewings oor jou AirPods te wys, bv. hul huidige status terwyl hulle gekoppel is.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,2 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"></resources>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
|
||||
<string name="app_name">CAPod</string>
|
||||
<string name="app_name_pro">CAPod Pro</string>
|
||||
<string name="app_name_foss">CAPod FOSS</string>
|
||||
<string name="general_value_not_available_label">á¨áá</string>
|
||||
<string name="general_error_label">áĩá
á°áĩ</string>
|
||||
<string name="general_grant_permission_action">áááĩ áĩáĨ</string>
|
||||
<string name="overview_nomaindevice_label">áá ááŖáĒáĢ á¨áá</string>
|
||||
<string name="overview_nomaindevice_description">á¨á°áááĩ ááá ááŖáĒáĢááŊ á¨áĨááĩá áááá ááŊáááĸ ááŖáĒáĢáá áĢáĨáŠá áĢááá ááá á
ááĨáŽášá áĢáĩá°áĢáááĸ</string>
|
||||
<string name="overview_bluetooth_disabled_label">áĨááąá á ááˇá</string>
|
||||
<string name="overview_bluetooth_disabled_description">áĨááąá á ááˇááŖ áĢáĨáŠáĩ ;)</string>
|
||||
<string name="permission_bluetooth_connect_label">á¨áĨááąá áááááĩ</string>
|
||||
<string name="permission_bluetooth_connect_description">áá
áá°áá áĒáĢ á¨á°áŖáአááŖáĒáĢááŊ áá ááááááĩ áĨá áááááļáŊá ááááá ᨠ\"áĨááąá áááááĩ\" áááĩ áĢáĩááááááĸ</string>
|
||||
<string name="permission_bluetooth_scan_label">á¨áĨááąá á
ááĩ</string>
|
||||
<string name="permission_bluetooth_scan_description">ᨠ\"áĨááąá á
ááĩ\" áááĩ áá
áá°áá áĒáĢ áĨáá° á¨áĨááĩá AirPods áĢá á á á
áĢáĸáĢ áĢá ááŖáĒáĢááŊ á¨áĨááąá áá¨áá áĨáá˛áĢáá áĨá áĨáá˛áá á áĢáĩáŊááááĸ</string>
|
||||
<string name="permission_bluetooth_label">áĨááąá</string>
|
||||
<string name="permission_bluetooth_description">áá
áá°áá áĒáĢ á¨á°áŖáአá¨áĨááąá ááŖáĒáĢááŊ áá ááááááĩ ᨠ\"áĨááąá\" áááĩ áĢáĩááááááĸ</string>
|
||||
<string name="permission_access_fine_location_label">áĩáááá á áĢáŖáĸá ááĩá¨áą</string>
|
||||
<string name="permission_access_fine_location_description">CAPod á¨áĨááąá áá
á°á ááá áá¨áá áááá á ᨠ\"áĩáááá á áĢáŖáĸ\" áááĩá áá ááááĸ á¨áĨááĩá á¨áᎠááŗááĢááŊ áááŗá¸áá ááá°áĢá¨áĩ á¨áĨááąá áá
á°á ááá á´ááááá áá ááááĸ áá
áá°áá áĒáĢ á áĢáŖáĸáá áááá°á á¨áĨááąá áá¨áá á áá ááááĸ</string>
|
||||
<string name="permission_background_location_label">á¨áŗáĢ á áĢáŖáĸ ááŗá¨áģ</string>
|
||||
<string name="permission_background_location_description">CAPod áá°áá áĒáĢá á˛áá áĨáá° \"áĨá
-áŖá á áŗá\" áĨá \"áĢáĩ-á°á áááááĩ\" áĢá áŖá
áĒáĢáĩá áááááĩ \"á¨áŗáĢ á áĢáŖáĸ ááŗá¨áģ\"á áá ááááĸ á¨áŗáĢ á áĢáŖáĸ ááŗá¨áģ áá°áá áĒáĢá á áŗáĢ ááĩáĨ áĨáĢá á¨áĨááąá áá
á°á ááá áá¨áá áĨáá˛áá á áĢáĩáŊááááĸ áá
áá°áá áĒáĢ á áĢáŖáĸáá áááá°á á¨áĨááąá áá¨áá á áá ááááĸ</string>
|
||||
<string name="permission_ignore_battery_optimizations_label">á¨áŖáĩáĒ áááģá¸áĩá á á°ááá</string>
|
||||
<string name="permission_ignore_battery_optimizations_description">á¨áŖáĩáĒ áááģá¸áĩ áá
áá°áá áĒáĢ á áŗáĢ ááĩáĨ áĨáĢá á¨áĨááąá áá¨áá á á áĩá°ááá áááŗ áĨááŗááá á áá¨áááááĸ</string>
|
||||
<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>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">á˛áŗá</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">áŦá áááĩ áá</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">á áᎠááĩáĨ</string>
|
||||
<string name="pods_dual_left_label">ááĢ ááĩ</string>
|
||||
<string name="pods_dual_right_label">áá ááĩ</string>
|
||||
<string name="pods_case_label">áŦá</string>
|
||||
<string name="pods_case_status_open_label">áááĩ</string>
|
||||
<string name="pods_case_status_closed_label">áá</string>
|
||||
<string name="pods_connection_state_disconnected_label">á¨ááŗáĒáĢ áá á áá°áááá</string>
|
||||
<string name="pods_connection_state_idle_label">á¨ááŗáĒáĢ áá á°ááááˇáãáá áĩáĢ ááĩ</string>
|
||||
<string name="pods_connection_state_music_label">á ááá áááŗ</string>
|
||||
<string name="pods_connection_state_call_label">á áĨáĒ áááŗ</string>
|
||||
<string name="pods_connection_state_ringing_label">áĨá¨á áĢ</string>
|
||||
<string name="pods_connection_state_hanging_up_label">áĨá¨áá</string>
|
||||
<string name="pods_connection_state_unknown_label">áĢááŗáá á¨áááááĩ áááŗ</string>
|
||||
<string name="pods_unknown_raw_data_label">áĨáŦ áá¨á</string>
|
||||
<string name="pods_unknown_label">áĢááŗáá ááŖáĒáĢ</string>
|
||||
<string name="pods_unknown_contact_dev">áá
áĢááŗáá ááŖáĒáĢ ááãáá á°ááŗáŗá á¨áááĨááĩ á
áá¸áĩ áĨá¨á°á áá áááĸ ááĨáą áĩáá áĨáá¨ááá áĩãá ááá :)</string>
|
||||
<string name="pods_none_label_short">ááŖáĒáĢ á¨áá</string>
|
||||
<string name="pods_charging_label">ááá áĨá¨áá</string>
|
||||
<string name="pods_inear_label">á áᎠááĩáĨ</string>
|
||||
<string name="pods_microphone_label">ááááŽáá</string>
|
||||
<string name="pods_yours">á¨áĨááĩá</string>
|
||||
<string name="headset_being_worn_label">áĨá¨á°áá á°</string>
|
||||
<string name="headset_not_being_worn_label">á áá°áá á°á</string>
|
||||
<string name="pods_case_unknown_state">áĢááŗáá áááŗ</string>
|
||||
<string name="last_seen_x">ááá¨á¨áģ áá á¨áŗá¨ááĻ %s</string>
|
||||
<string name="first_seen_x">áááááĒáĢ áá á¨áŗá¨ááĻ %s</string>
|
||||
<string name="permission_post_notifications_label">ááŗáááĢááŊá á áŗá</string>
|
||||
<string name="permission_post_notifications_description">"CAPod áĩá á¨áĨááĩá AirPods ááŗáááĢááŊá áĨáá˛áĢáŗá ááááąááĩáŖ áááŗá á˛ááá á¨á áá áááŗá¸ááĸ"</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
|
||||
<string name="app_name">CAPod</string>
|
||||
<string name="app_name_pro">CAPod Pro</string>
|
||||
<string name="app_name_pro">CAPod et Pro</string>
|
||||
<string name="app_name_foss">CAPod FOSS</string>
|
||||
<string name="general_value_not_available_label">ØēŲØą Ų
ØĒŲŲØą</string>
|
||||
<string name="general_error_label">ØŽØˇØŖ</string>
|
||||
@@ -51,11 +51,14 @@
|
||||
<string name="pods_unknown_contact_dev">ŲØ°Ø§ ØŦŲØ§Ø˛ ØēŲØą Ų
ØšØąŲŲ ŲŲŲŲ ŲØŗØĒ؎دŲ
ØĩŲØēØŠ ØąØŗØ§ŲØŠ Ų
Ø´Ø§Ø¨ŲØŠ. Ø¯ØšŲØ§ ŲØļŲŲ Ø¯ØšŲ
ا ŲŲØ§. اØĒØĩŲ Ø¨Ų :)</string>
|
||||
<string name="pods_none_label_short">ŲØ§ ŲŲØŦد ØŦŲØ§Ø˛</string>
|
||||
<string name="pods_charging_label">ŲŲ ØØ§ŲØŠ Ø§ŲØ´ØŲ</string>
|
||||
<string name="pods_inear_label">ŲŲ Ø§ŲØŖØ°Ų</string>
|
||||
<string name="pods_inear_label">ŲŲ Ø§ŲØŖØ°Ų ŲØšŲ
</string>
|
||||
<string name="pods_microphone_label">Ų
ŲŲØąŲŲŲŲ</string>
|
||||
<string name="pods_yours">Ø§ŲØŽØ§ØĩØŠ بŲ</string>
|
||||
<string name="headset_being_worn_label">ŲØĒŲ
Ø§ØąØĒØ¯Ø§Ø¤ŲØ§</string>
|
||||
<string name="headset_not_being_worn_label">ŲØ§ ŲØĒŲ
Ø§ØąØĒØ¯Ø§Ø¤ŲØ§</string>
|
||||
<string name="pods_case_unknown_state">ØØ§ŲØŠ ØēŲØą Ų
ØšØąŲŲØŠ</string>
|
||||
<string name="last_seen_x">ØĸØŽØą ظŲŲØą: %s</string>
|
||||
<string name="first_seen_x">ØŖŲŲ Ø¸ŲŲØą:%s</string>
|
||||
<string name="permission_post_notifications_label">ØĨØ¸ŲØ§Øą Ø§ŲØĨØ´ØšØ§ØąØ§ØĒ</string>
|
||||
<string name="permission_post_notifications_description">"Ø§ØŗŲ
Ø ŲŲŲØĒØˇØ¨ŲŲ Ø¨ØĨØ¸ŲØ§Øą Ø§ŲØĨØ´ØšØ§ØąØ§ØĒ اŲŲ
ØĒØšŲŲØŠ Ø¨ØŗŲ
اؚاØĒ Ø§ŲØŖØ°Ų Ø§ŲØŽØ§ØĩØŠ Ø¨ŲØ ØšŲŲ ØŗØ¨ŲŲ Ø§ŲŲ
ØĢاŲ. ØØ§ŲØĒŲŲ
Ø§ŲØØ§ŲŲØŠ ØŽŲØ§Ų Ø§ŲØ§ØĒØĩاŲ."</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
|
||||
<string name="app_name">CAPod</string>
|
||||
<string name="app_name_pro">CAPod Pro</string>
|
||||
<string name="app_name_foss">CAPod FOSS</string>
|
||||
|
||||
<string name="general_value_not_available_label">MÃļvcud deyil</string>
|
||||
<string name="general_error_label">XÉta</string>
|
||||
<string name="general_grant_permission_action">İcazÉ ver</string>
|
||||
@@ -11,6 +15,7 @@
|
||||
<string name="permission_bluetooth_connect_description">Bu tÉtbiq, cÃŧtlÉÅdirilmiÅ cihazlarla qarÅÄąlÄąqlÄą ÉlaqÉdÉ olmaq vÉ baÄlantÄąlarÄą baÅlatmaq ÃŧçÃŧn \"Bluetooth baÄlantÄąsÄą\" icazÉsini tÉlÉb edir.</string>
|
||||
<string name="permission_bluetooth_scan_label">Bluetooth skan etmÉ</string>
|
||||
<string name="permission_bluetooth_scan_description">\"Bluetooth skan etmÉ\" icazÉsi, tÉtbiqÉ AirPods kimi yaxÄąnlÄąqda olan cihazlardan Bluetooth verilÉnlÉrini almaÄa vÉ kÉÅf etmÉyÉ icazÉ verir.</string>
|
||||
<string name="permission_bluetooth_label">Bluetooth</string>
|
||||
<string name="permission_bluetooth_description">Bu tÉtbiq, cÃŧtlÉÅdirilmiÅ bluetooth cihazlarÄąna baÄlanmaq ÃŧçÃŧn \"Bluetooth\" icazÉsini tÉlÉb edir.</string>
|
||||
<string name="permission_access_fine_location_label">HÉssas yerlÉÅmÉyÉ mÃŧraciÉt</string>
|
||||
<string name="permission_access_fine_location_description">CAPod, Bluetooth ZÉif Enerji verilÉnlÉrini almaq ÃŧçÃŧn \"dÉqiq yerlÉÅmÉ\" icazÉsini istifadÉ edir. QulaqlÄąqlarÄąnÄąz, Ãļz vÉziyyÉtlÉrini (qalan batereya faizini) yayÄąmlamaq Bluetooth ZÉif Enerji texnologiyasÄąnÄą istifadÉ edir. Bu tÉtbiq, Bluetooth verilÉnlÉrini yerlÉÅmÉnizi mÃŧÉyyÉnlÉÅdirmÉk ÃŧçÃŧn İSTİFADÆ ETMİR.</string>
|
||||
@@ -51,7 +56,10 @@
|
||||
<string name="pods_microphone_label">Mikrofon</string>
|
||||
<string name="pods_yours">Sizin</string>
|
||||
<string name="headset_being_worn_label">TaxÄąlÄąr</string>
|
||||
<string name="headset_not_being_worn_label">TaxÄąlmayÄąb</string>
|
||||
<string name="pods_case_unknown_state">BilinmÉyÉn vÉziyyÉt</string>
|
||||
<string name="last_seen_x">Son gÃļrÃŧnmÉ: %s</string>
|
||||
<string name="first_seen_x">İlk gÃļrÃŧnmÉ: %s</string>
|
||||
<string name="permission_post_notifications_label">BildiriÅlÉri gÃļstÉr</string>
|
||||
<string name="permission_post_notifications_description">CAPod-a AirPods-larÄąnÄąz haqqÄąnda bildiriÅlÉr gÃļstÉrmÉyÉ icazÉ verin, mÉsÉlÉn, qoÅulmuÅ vÉziyyÉtdÉ olduqlarÄą zaman onlarÄąn mÃļvcud statusu.</string>
|
||||
</resources>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<string name="app_name">CAPod</string>
|
||||
<string name="app_name_pro">CAPod Pro</string>
|
||||
<string name="app_name_foss">CAPod FOSS</string>
|
||||
|
||||
<string name="general_value_not_available_label">Đ/Đ´</string>
|
||||
<string name="general_error_label">ĐаĐŧŅĐģĐēа</string>
|
||||
<string name="general_grant_permission_action">ĐаŅŅ Đ´Đ°ĐˇĐ˛ĐžĐģ</string>
|
||||
@@ -13,7 +14,7 @@
|
||||
<string name="permission_bluetooth_connect_label">ĐĐģŅŅŅĐŊĐŊĐĩ Đŋа Bluetooth</string>
|
||||
<string name="permission_bluetooth_connect_description">ĐŅŅаК ĐŋŅĐ°ĐŗŅаĐŧĐĩ ĐŋаŅŅайŅĐĩŅŅа даСвОĐģ ĐŊа вŅĐēаŅŅŅŅаĐŊĐŊĐĩ Bluetooth Đ´ĐģŅ ŅСаĐĩĐŧадСĐĩŅĐŊĐŊŅ Ņа ŅĐŋаĐģŅŅаĐŊŅĐŧŅ ĐŋŅŅĐģадаĐŧŅ Ņ ŅŅваŅŅĐŊĐŊŅ ĐŊОвŅŅ
СĐģŅŅŅĐŊĐŊŅŅ.</string>
|
||||
<string name="permission_bluetooth_scan_label">ĐĄĐēаĐŊŅŅаваĐŊĐŊĐĩ Bluetooth</string>
|
||||
<string name="permission_bluetooth_scan_description">ĐаСвОĐģ ĐŊа ŅĐēаĐŊŅŅаваĐŊĐŊĐĩ Bluetooth даŅŅŅ ĐŧĐ°ĐŗŅŅĐŧаŅŅŅ ĐŗŅŅаК ĐŋŅĐ°ĐŗŅаĐŧĐĩ вŅŅŅĐģŅŅŅ Ņ Đ°ŅŅŅĐŧĐģŅваŅŅ Đ´Đ°ĐŊŅŅ Đŋа Bluetooth С ĐŊаваĐēĐžĐģŅĐŊŅŅ
ĐŋŅŅĐģад, ŅаĐēŅŅ
ŅĐē ваŅŅ AirPods.</string>
|
||||
<string name="permission_bluetooth_scan_description">ĐаСвОĐģ ĐŊа ŅĐēаĐŊŅŅаваĐŊĐŊĐĩ Bluetooth даŅŅŅ ĐŧĐ°ĐŗŅŅĐŧаŅŅŅ ĐŗŅŅаК ĐŋŅĐ°ĐŗŅаĐŧĐĩ вŅŅŅĐģŅŅŅ Ņ Đ°ŅŅŅĐŧĐģŅваŅŅ Đ´Đ°ĐŊŅŅ Đŋа Bluetooth С ĐŊаваĐēĐžĐģŅĐŊŅŅ
ĐŋŅŅĐģад, ŅаĐēŅŅ
ŅĐē ваŅŅ AirPods.</string>
|
||||
<string name="permission_bluetooth_label">Bluetooth</string>
|
||||
<string name="permission_bluetooth_description">ĐŅŅаК ĐŋŅĐ°ĐŗŅаĐŧĐĩ ĐŋаŅŅайŅĐĩŅŅа даСвОĐģ ĐŊа вŅĐēаŅŅŅŅаĐŊĐŊĐĩ Bluetooth Đ´ĐģŅ ĐˇĐģŅŅŅĐŊĐŊŅ Ņа ŅĐŋаĐģŅŅаĐŊŅĐŧŅ ĐŋŅŅĐģадаĐŧŅ.</string>
|
||||
<string name="permission_access_fine_location_label">ĐĐžŅŅŅĐŋ да даĐēĐģадĐŊĐ°ĐŗĐ° ĐŧĐĩŅŅаСĐŊаŅ
ОдĐļаĐŊĐŊŅ</string>
|
||||
@@ -55,7 +56,10 @@
|
||||
<string name="pods_microphone_label">ĐŅĐēŅаŅĐžĐŊ</string>
|
||||
<string name="pods_yours">ĐаŅŅ</string>
|
||||
<string name="headset_being_worn_label">ĐадСĐĩŅŅ</string>
|
||||
<string name="headset_not_being_worn_label">ĐĐĩ ĐŊадСĐĩŅŅ</string>
|
||||
<string name="pods_case_unknown_state">ĐĐĩвŅĐ´ĐžĐŧŅ ŅŅаĐŊ</string>
|
||||
<string name="last_seen_x">ĐĐŋĐžŅĐŊŅ ŅаС ĐąŅŅ: %s</string>
|
||||
<string name="first_seen_x">ĐŖĐŋĐĩŅŅŅĐŊŅ ĐąŅŅ: %s</string>
|
||||
<string name="permission_post_notifications_label">ĐаĐēаСваŅŅ Đ°ĐŋавŅŅŅŅĐŊĐŊŅ</string>
|
||||
<string name="permission_post_notifications_description">"ĐаСвОĐģŅŅŅ CAPod адĐģŅŅŅŅĐžŅваŅŅ Đ°ĐŋавŅŅŅŅĐŊĐŊŅ Đ°Đą ваŅŅŅ
AirPods: ĐŊаĐŋŅŅĐēĐģад, ŅŅ
ĐąŅĐŗŅŅŅ ŅŅаĐŊ ĐŋŅŅ ĐŋадĐēĐģŅŅŅĐŊĐŊŅ."</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,2 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"></resources>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
|
||||
<string name="app_name">CAPod</string>
|
||||
<string name="app_name_pro">CAPod Pro</string>
|
||||
<string name="app_name_foss">CAPod FOSS</string>
|
||||
|
||||
<string name="general_value_not_available_label">ĐŅĐŧа даĐŊĐŊи</string>
|
||||
<string name="general_error_label">ĐŅĐĩŅĐēа</string>
|
||||
<string name="general_grant_permission_action">ĐаваĐŊĐĩ ĐŊа ŅаСŅĐĩŅĐĩĐŊиĐĩ</string>
|
||||
|
||||
<string name="overview_nomaindevice_label">ĐŅĐŧа ĐžŅĐŊОвĐŊĐž ŅŅŅŅОКŅŅвО</string>
|
||||
<string name="overview_nomaindevice_description">ĐŅиŅĐēи ĐžŅĐēŅиŅи ŅŅŅŅОКŅŅва вĐĩŅĐžŅŅĐŊĐž ĐŊĐĩ Ņа ваŅи. ĐĐēĐģŅŅĐĩŅĐĩ и ŅвŅŅĐļĐĩŅĐĩ ваŅĐĩŅĐž ŅŅŅŅОКŅŅвО иĐģи ĐēĐžŅĐ¸ĐŗĐ¸ŅаКŅĐĩ ĐŊаŅŅŅОКĐēиŅĐĩ.</string>
|
||||
<string name="overview_bluetooth_disabled_label">Bluetooth Đĩ Đ´ĐĩаĐēŅивиŅаĐŊ</string>
|
||||
<string name="overview_bluetooth_disabled_description">Bluetooth Đĩ Đ´ĐĩаĐēŅивиŅаĐŊ, аĐēŅивиŅаКŅĐĩ ĐŗĐž ;)</string>
|
||||
|
||||
<string name="permission_bluetooth_connect_label">Bluetooth ŅвŅŅСваĐŊĐĩ</string>
|
||||
<string name="permission_bluetooth_connect_description">ĐĸОва ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩ иСиŅĐēва ŅаСŅĐĩŅĐĩĐŊиĐĩŅĐž \"Bluetooth ŅвŅŅСваĐŊĐĩ\" Са вСаиĐŧОдĐĩĐšŅŅвиĐĩ ŅŅŅ ŅдвОĐĩĐŊи ŅŅŅŅОКŅŅва и иĐŊиŅииŅаĐŊĐĩ ĐŊа вŅŅСĐēи.</string>
|
||||
<string name="permission_bluetooth_scan_label">Bluetooth ŅĐēаĐŊиŅаĐŊĐĩ</string>
|
||||
<string name="permission_bluetooth_scan_description">РаСŅĐĩŅĐĩĐŊиĐĩŅĐž \"Bluetooth ŅĐēаĐŊиŅаĐŊĐĩ\" ĐŋОСвОĐģŅва ĐŊа ŅОва ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩ да ĐžŅĐēŅива и ĐŋĐžĐģŅŅава Bluetooth даĐŊĐŊи ĐžŅ ĐąĐģиСĐēи ŅŅŅŅОКŅŅва ĐēаŅĐž ваŅиŅĐĩ AirPods.</string>
|
||||
<string name="permission_bluetooth_label">Bluetooth</string>
|
||||
<string name="permission_bluetooth_description">ĐĸОва ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩ иСиŅĐēва ŅаСŅĐĩŅĐĩĐŊиĐĩŅĐž \"Bluetooth\" Са ŅвŅŅСваĐŊĐĩ ŅŅŅ ŅдвОĐĩĐŊи Bluetooth ŅŅŅŅОКŅŅва.</string>
|
||||
<string name="permission_access_fine_location_label">ĐĐžŅŅŅĐŋ Đ´Đž ŅĐžŅĐŊĐž ĐŧĐĩŅŅĐžĐŋĐžĐģĐžĐļĐĩĐŊиĐĩ</string>
|
||||
<string name="permission_access_fine_location_description">CAPod иСĐŋĐžĐģСва ŅаСŅĐĩŅĐĩĐŊиĐĩŅĐž \"ŅĐžŅĐŊĐž ĐŧĐĩŅŅĐžĐŋĐžĐģĐžĐļĐĩĐŊиĐĩ\" Са ĐŋĐžĐģŅŅаваĐŊĐĩ ĐŊа Bluetooth Low Energy даĐŊĐŊи. ĐаŅиŅĐĩ ŅĐģŅŅаĐģĐēи иСĐŋĐžĐģĐˇĐ˛Đ°Ņ Bluetooth Low Energy ŅĐĩŅ
ĐŊĐžĐģĐžĐŗĐ¸Ņ ĐˇĐ° иСĐģŅŅваĐŊĐĩ ĐŊа ŅŅŅŅĐžŅĐŊиĐĩŅĐž Ņи. ĐĸОва ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩ ĐĐ¯ĐРда иСĐŋĐžĐģСва Bluetooth даĐŊĐŊи Са ĐžĐŋŅĐĩĐ´ĐĩĐģŅĐŊĐĩ ĐŊа ваŅĐĩŅĐž ĐŧĐĩŅŅĐžĐŋĐžĐģĐžĐļĐĩĐŊиĐĩ.</string>
|
||||
<string name="permission_background_location_label">ĐĐžŅŅŅĐŋ Đ´Đž ĐŧĐĩŅŅĐžĐŋĐžĐģĐžĐļĐĩĐŊиĐĩ вŅв ŅĐžĐŊОв ŅĐĩĐļиĐŧ</string>
|
||||
<string name="permission_background_location_description">CAPod иСĐŋĐžĐģСва \"Đ´ĐžŅŅŅĐŋ Đ´Đž ĐŧĐĩŅŅĐžĐŋĐžĐģĐžĐļĐĩĐŊиĐĩ вŅв ŅĐžĐŊОв ŅĐĩĐļиĐŧ\", Са да аĐēŅивиŅа ŅŅĐŊĐēŅии ĐēаŅĐž \"ĐĐžĐēаСваĐŊĐĩ ĐŊа иСŅĐēаŅĐ°Ņ ĐŋŅОСОŅĐĩŅ\" и \"ĐвŅĐžĐŧаŅиŅĐŊĐž ŅвŅŅСваĐŊĐĩ\", Đ´ĐžĐēаŅĐž ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩŅĐž Đĩ СаŅвОŅĐĩĐŊĐž. ĐĐžŅŅŅĐŋŅŅ Đ´Đž ĐŧĐĩŅŅĐžĐŋĐžĐģĐžĐļĐĩĐŊиĐĩ вŅв ŅĐžĐŊОв ŅĐĩĐļиĐŧ ĐŋОСвОĐģŅва ĐŊа ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩŅĐž да ĐŋĐžĐģŅŅава Bluetooth Low Energy даĐŊĐŊи, Đ´ĐžĐēаŅĐž Đĩ вŅв ŅĐžĐŊОв ŅĐĩĐļиĐŧ. ĐĸОва ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩ ĐĐ¯ĐРда иСĐŋĐžĐģСва Bluetooth даĐŊĐŊи Са ĐžĐŋŅĐĩĐ´ĐĩĐģŅĐŊĐĩ ĐŊа ваŅĐĩŅĐž ĐŧĐĩŅŅĐžĐŋĐžĐģĐžĐļĐĩĐŊиĐĩ.</string>
|
||||
<string name="permission_ignore_battery_optimizations_label">ĐĐĩаĐēŅивиŅаĐŊĐĩ ĐŊа ĐžĐŋŅиĐŧиСаŅииŅĐĩ ĐŊа йаŅĐĩŅиŅŅа</string>
|
||||
<string name="permission_ignore_battery_optimizations_description">ĐĐŋŅиĐŧиСаŅииŅĐĩ ĐŊа йаŅĐĩŅиŅŅа ĐŋŅĐĩŅĐ°Ņ ĐŊа ŅОва ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩ да ĐŋĐžĐģŅŅава ĐŊадĐĩĐļĐ´ĐŊĐž Bluetooth даĐŊĐŊи, Đ´ĐžĐēаŅĐž ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩŅĐž Đĩ вŅв ŅĐžĐŊОв ŅĐĩĐļиĐŧ.</string>
|
||||
<string name="permission_required_title">ĐСиŅĐēва ŅĐĩ ŅĐģĐĩĐ´ĐŊĐžŅĐž ŅаСŅĐĩŅĐĩĐŊиĐĩ:</string>
|
||||
<string name="permission_system_alert_window_label">ĐŅОСОŅĐĩŅ ĐˇĐ° ŅиŅŅĐĩĐŧĐŊи ĐŋŅĐĩĐ´ŅĐŋŅĐĩĐļĐ´ĐĩĐŊиŅ</string>
|
||||
<string name="permission_system_alert_window_description">РаСŅĐĩŅĐĩŅĐĩ ĐŊа CAPod да ŅиŅŅва вŅŅŅ
Ņ Đ´ŅŅĐŗĐ¸ ĐŋŅиĐģĐžĐļĐĩĐŊиŅ, Са да ĐŊаĐŋŅави вŅСĐŧĐžĐļĐŊа ŅŅĐŊĐēŅиŅŅа \"ĐĐžĐēаСваĐŊĐĩ ĐŊа PopUp\".</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>
|
||||
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">ĐĐžĐŗĐ°ŅĐž ŅĐĩ види</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">ĐаĐģŅŅŅŅ Đĩ ĐžŅвОŅĐĩĐŊ</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">Đ ŅŅ
ĐžŅĐž</string>
|
||||
|
||||
<string name="pods_dual_left_label">ĐŅва ŅĐģŅŅаĐģĐēа</string>
|
||||
<string name="pods_dual_right_label">ĐŅŅĐŊа ŅĐģŅŅаĐģĐēа</string>
|
||||
<string name="pods_case_label">ĐаĐģŅŅ</string>
|
||||
<string name="pods_case_status_open_label">ĐŅвОŅĐĩĐŊ</string>
|
||||
<string name="pods_case_status_closed_label">ĐаŅвОŅĐĩĐŊ</string>
|
||||
<string name="pods_connection_state_disconnected_label">ĐĐĩ Đĩ ŅвŅŅСаĐŊĐž Ņ ŅŅŅŅОКŅŅвО</string>
|
||||
<string name="pods_connection_state_idle_label">ХвŅŅСаĐŊĐž Ņ ŅŅŅŅОКŅŅвО, ĐŊĐž ĐŊĐĩаĐēŅивĐŊĐž</string>
|
||||
<string name="pods_connection_state_music_label">Đ ĐŧŅСиĐēаĐģĐĩĐŊ ŅĐĩĐļиĐŧ</string>
|
||||
<string name="pods_connection_state_call_label">Đ ŅĐĩĐļиĐŧ ĐŊа ĐŋОвиĐēваĐŊĐĩ</string>
|
||||
<string name="pods_connection_state_ringing_label">ĐвŅĐŊĐĩĐŊĐĩ</string>
|
||||
<string name="pods_connection_state_hanging_up_label">ĐаŅваŅŅĐŊĐĩ</string>
|
||||
<string name="pods_connection_state_unknown_label">ĐĐĩиСвĐĩŅŅĐŊĐž ŅŅŅŅĐžŅĐŊиĐĩ ĐŊа вŅŅСĐēаŅа</string>
|
||||
<string name="pods_unknown_raw_data_label">ĐĄŅŅОви даĐŊĐŊи</string>
|
||||
<string name="pods_unknown_label">ĐĐĩиСвĐĩŅŅĐŊĐž ŅŅŅŅОКŅŅвО</string>
|
||||
<string name="pods_unknown_contact_dev">ĐĸОва Đĩ ĐŊĐĩиСвĐĩŅŅĐŊĐž ŅŅŅŅОКŅŅвО, ĐŊĐž иСĐŋĐžĐģСва ĐŋОдОйĐĩĐŊ ŅĐžŅĐŧĐ°Ņ ĐŊа ŅŅОйŅĐĩĐŊиŅŅа. ĐĐĩĐēа дОйавиĐŧ ĐŋОддŅŅĐļĐēа Са ĐŊĐĩĐŗĐž, ŅвŅŅĐļĐĩŅĐĩ ŅĐĩ Ņ ĐŧĐĩĐŊ :)</string>
|
||||
<string name="pods_none_label_short">ĐŅĐŧа ŅŅŅŅОКŅŅвО</string>
|
||||
<string name="pods_charging_label">ĐаŅĐĩĐļдаĐŊĐĩ</string>
|
||||
<string name="pods_inear_label">Đ ŅŅ
ĐžŅĐž</string>
|
||||
<string name="pods_microphone_label">ĐиĐēŅĐžŅĐžĐŊ</string>
|
||||
<string name="pods_yours">ĐаŅи</string>
|
||||
<string name="headset_being_worn_label">ĐĐžŅи ŅĐĩ</string>
|
||||
<string name="headset_not_being_worn_label">ĐĐĩ ŅĐĩ ĐŊĐžŅи</string>
|
||||
<string name="pods_case_unknown_state">ĐĐĩиСвĐĩŅŅĐŊĐž ŅŅŅŅĐžŅĐŊиĐĩ</string>
|
||||
|
||||
<string name="last_seen_x">ĐĐžŅĐģĐĩĐ´ĐŊĐž видŅĐŊĐž: %s</string>
|
||||
<string name="first_seen_x">ĐŅŅвО видŅĐŊĐž: %s</string>
|
||||
<string name="permission_post_notifications_label">ĐĐžĐēаСваĐŊĐĩ ĐŊа иСвĐĩŅŅиŅ</string>
|
||||
<string name="permission_post_notifications_description">РаСŅĐĩŅĐĩŅĐĩ ĐŊа CAPod да ĐŋĐžĐēаСва иСвĐĩŅŅĐ¸Ņ ĐˇĐ° ваŅиŅĐĩ AirPods, ĐŊаĐŋŅ. ŅĐĩĐēŅŅĐžŅĐž иĐŧ ŅŅŅŅĐžŅĐŊиĐĩ, Đ´ĐžĐēаŅĐž Ņа ŅвŅŅСаĐŊи.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
|
||||
<string name="app_name">CAPod</string>
|
||||
<string name="app_name_pro">CAPod Pro</string>
|
||||
<string name="app_name_foss">CAPod FOSS</string>
|
||||
|
||||
<string name="general_value_not_available_label">āĻĒā§āϰāϝā§āĻā§āϝ āύāϝāĻŧ</string>
|
||||
<string name="general_error_label">āϤā§āϰā§āĻāĻŋ</string>
|
||||
<string name="general_grant_permission_action">āĻ
āύā§āĻŽāϤāĻŋ āĻĒā§āϰāĻĻāĻžāύ āĻāϰā§āύ</string>
|
||||
|
||||
<string name="overview_nomaindevice_label">āĻā§āύāĻ āĻĒā§āϰāĻžāĻĨāĻŽāĻŋāĻ āĻĄāĻŋāĻāĻžāĻāϏ āύā§āĻ</string>
|
||||
<string name="overview_nomaindevice_description">āĻļāύāĻžāĻā§āϤ āĻāϰāĻž āϏāĻŽāϏā§āϤ āĻĄāĻŋāĻāĻžāĻāϏ āϏāĻŽā§āĻāĻŦāϤ āĻāĻĒāύāĻžāϰ āύāϝāĻŧāĨ¤ āĻāĻĒāύāĻžāϰ āĻĄāĻŋāĻāĻžāĻāϏ āĻāĻžāϞ⧠āĻāϰā§āύ āĻāĻŦāĻ āϏāĻāϝā§āĻ āĻāϰā§āύ āĻ
āĻĨāĻŦāĻž āϏā§āĻāĻŋāĻāϏ āϏāĻžāĻŽāĻā§āĻāϏā§āϝ āĻāϰā§āύāĨ¤</string>
|
||||
<string name="overview_bluetooth_disabled_label">āĻŦā§āϞā§āĻā§āĻĨ āύāĻŋāώā§āĻā§āϰāĻŋāϝāĻŧ āĻāϰāĻž āĻāĻā§</string>
|
||||
<string name="overview_bluetooth_disabled_description">āĻŦā§āϞā§āĻā§āĻĨ āύāĻŋāώā§āĻā§āϰāĻŋāϝāĻŧ āĻāϰāĻž āĻāĻā§, āĻāĻāĻŋ āϏāĻā§āϰāĻŋāϝāĻŧ āĻāϰā§āύ ;)</string>
|
||||
|
||||
<string name="permission_bluetooth_connect_label">āĻŦā§āϞā§āĻā§āĻĨ āϏāĻāϝā§āĻ</string>
|
||||
<string name="permission_bluetooth_connect_description">āĻāĻ āĻ
ā§āϝāĻžāĻĒāĻāĻŋāϰ āĻĒā§āϝāĻŧāĻžāϰ āĻāϰāĻž āĻĄāĻŋāĻāĻžāĻāϏāĻā§āϞāĻŋāϰ āϏāĻžāĻĨā§ āĻāύā§āĻāĻžāϰāĻ
ā§āϝāĻžāĻā§āĻ āĻāϰāϤ⧠āĻāĻŦāĻ āϏāĻāϝā§āĻ āĻļā§āϰ⧠āĻāϰāϤ⧠"āĻŦā§āϞā§āĻā§āĻĨ āϏāĻāϝā§āĻ" āĻ
āύā§āĻŽāϤāĻŋāϰ āĻĒā§āϰāϝāĻŧā§āĻāύāĨ¤</string>
|
||||
<string name="permission_bluetooth_scan_label">āĻŦā§āϞā§āĻā§āĻĨ āϏā§āĻā§āϝāĻžāύāĻŋāĻ</string>
|
||||
<string name="permission_bluetooth_scan_description">"āĻŦā§āϞā§āĻā§āĻĨ āϏā§āĻā§āϝāĻžāύāĻŋāĻ" āĻ
āύā§āĻŽāϤāĻŋ āĻāĻ āĻ
ā§āϝāĻžāĻĒāĻāĻŋāĻā§ āĻāĻĒāύāĻžāϰ AirPods-āĻāϰ āĻŽāϤ⧠āĻāĻžāĻāĻžāĻāĻžāĻāĻŋ āĻĄāĻŋāĻāĻžāĻāϏāĻā§āϞāĻŋ āĻĨā§āĻā§ āĻŦā§āϞā§āĻā§āĻĨ āĻĄā§āĻāĻž āĻāĻŦāĻŋāώā§āĻāĻžāϰ āĻāĻŦāĻ āĻā§āϰāĻšāĻŖ āĻāϰāϤ⧠āĻĻā§āϝāĻŧāĨ¤</string>
|
||||
<string name="permission_bluetooth_label">āĻŦā§āϞā§āĻā§āĻĨ</string>
|
||||
<string name="permission_bluetooth_description">āĻāĻ āĻ
ā§āϝāĻžāĻĒāĻāĻŋāϰ āĻĒā§āϝāĻŧāĻžāϰ āĻāϰāĻž āĻŦā§āϞā§āĻā§āĻĨ āĻĄāĻŋāĻāĻžāĻāϏāĻā§āϞāĻŋāϰ āϏāĻžāĻĨā§ āϏāĻāϝā§āĻ āĻāϰāϤ⧠"āĻŦā§āϞā§āĻā§āĻĨ" āĻ
āύā§āĻŽāϤāĻŋāϰ āĻĒā§āϰāϝāĻŧā§āĻāύāĨ¤</string>
|
||||
<string name="permission_access_fine_location_label">āϏāĻ āĻŋāĻ āĻ
āĻŦāϏā§āĻĨāĻžāύ⧠āĻ
ā§āϝāĻžāĻā§āϏā§āϏ</string>
|
||||
<string name="permission_access_fine_location_description">CAPod āĻŦā§āϞā§āĻā§āĻĨ āϞ⧠āĻāύāĻžāϰā§āĻāĻŋ āĻĄā§āĻāĻž āĻā§āϰāĻšāĻŖ āĻāϰāϤ⧠"āϏāĻ āĻŋāĻ āĻ
āĻŦāϏā§āĻĨāĻžāύ" āĻ
āύā§āĻŽāϤāĻŋ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰā§āĨ¤ āĻāĻĒāύāĻžāϰ āĻšā§āĻĄāĻĢā§āύāĻā§āϞāĻŋ āϤāĻžāĻĻā§āϰ āϏā§āĻĨāĻŋāϤāĻŋ āϏāĻŽā§āĻĒā§āϰāĻāĻžāϰ āĻāϰāϤ⧠āĻŦā§āϞā§āĻā§āĻĨ āϞ⧠āĻāύāĻžāϰā§āĻāĻŋ āĻĒā§āϰāϝā§āĻā§āϤāĻŋ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰā§āĨ¤ āĻāĻ āĻ
ā§āϝāĻžāĻĒāĻāĻŋ āĻāĻĒāύāĻžāϰ āĻ
āĻŦāϏā§āĻĨāĻžāύ āύāĻŋāϰā§āϧāĻžāϰāĻŖ āĻāϰāϤ⧠āĻŦā§āϞā§āĻā§āĻĨ āĻĄā§āĻāĻž āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰāĻŦā§ āύāĻžāĨ¤</string>
|
||||
<string name="permission_background_location_label">āĻĒāĻāĻā§āĻŽāĻŋ āĻ
āĻŦāϏā§āĻĨāĻžāύ⧠āĻ
ā§āϝāĻžāĻā§āϏā§āϏ</string>
|
||||
<string name="permission_background_location_description">āĻ
ā§āϝāĻžāĻĒāĻāĻŋ āĻŦāύā§āϧ āĻĨāĻžāĻāĻž āĻ
āĻŦāϏā§āĻĨāĻžāϝāĻŧ "āĻĒāĻĒāĻāĻĒ āĻĻā§āĻāĻžāύ" āĻāĻŦāĻ "āϏā§āĻŦāϝāĻŧāĻāĻā§āϰāĻŋāϝāĻŧ āϏāĻāϝā§āĻ" āĻāϰ āĻŽāϤ⧠āĻŦā§āĻļāĻŋāώā§āĻā§āϝāĻā§āϞāĻŋ āϏāĻā§āώāĻŽ āĻāϰāϤ⧠CAPod "āĻĒāĻāĻā§āĻŽāĻŋ āĻ
āĻŦāϏā§āĻĨāĻžāύ⧠āĻ
ā§āϝāĻžāĻā§āϏā§āϏ" āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰā§āĨ¤ āĻĒāĻāĻā§āĻŽāĻŋ āĻ
āĻŦāϏā§āĻĨāĻžāύ⧠āĻ
ā§āϝāĻžāĻā§āϏā§āϏ āĻ
ā§āϝāĻžāĻĒāĻāĻŋāĻā§ āĻĒāĻāĻā§āĻŽāĻŋāϤ⧠āĻĨāĻžāĻāĻžāĻāĻžāϞā§āύ āĻŦā§āϞā§āĻā§āĻĨ āϞ⧠āĻāύāĻžāϰā§āĻāĻŋ āĻĄā§āĻāĻž āĻā§āϰāĻšāĻŖ āĻāϰāϤ⧠āĻĻā§āϝāĻŧāĨ¤ āĻāĻ āĻ
ā§āϝāĻžāĻĒāĻāĻŋ āĻāĻĒāύāĻžāϰ āĻ
āĻŦāϏā§āĻĨāĻžāύ āύāĻŋāϰā§āϧāĻžāϰāĻŖ āĻāϰāϤ⧠āĻŦā§āϞā§āĻā§āĻĨ āĻĄā§āĻāĻž āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰāĻŦā§ āύāĻžāĨ¤</string>
|
||||
<string name="permission_ignore_battery_optimizations_label">āĻŦā§āϝāĻžāĻāĻžāϰāĻŋ āĻ
āĻĒā§āĻāĻŋāĻŽāĻžāĻāĻā§āĻļāύ āύāĻŋāώā§āĻā§āϰāĻŋāϝāĻŧ āĻāϰā§āύ</string>
|
||||
<string name="permission_ignore_battery_optimizations_description">āĻ
ā§āϝāĻžāĻĒāĻāĻŋ āĻĒāĻāĻā§āĻŽāĻŋāϤ⧠āĻĨāĻžāĻāĻžāĻāĻžāϞā§āύ āĻŦā§āϝāĻžāĻāĻžāϰāĻŋ āĻ
āĻĒā§āĻāĻŋāĻŽāĻžāĻāĻā§āĻļāύ āĻāĻ āĻ
ā§āϝāĻžāĻĒāĻāĻŋāĻā§ āύāĻŋāϰā§āĻāϰāϝā§āĻā§āϝāĻāĻžāĻŦā§ āĻŦā§āϞā§āĻā§āĻĨ āĻĄā§āĻāĻž āĻā§āϰāĻšāĻŖ āĻāϰāϤ⧠āĻŦāĻžāϧāĻž āĻĻā§āϝāĻŧāĨ¤</string>
|
||||
<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>
|
||||
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">āĻĻā§āĻāϞā§</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">āĻā§āϏ āĻā§āϞāĻž āĻāĻā§</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">āĻāĻžāύā§</string>
|
||||
|
||||
<string name="pods_dual_left_label">āĻŦāĻžāĻŽ āĻĒāĻĄ</string>
|
||||
<string name="pods_dual_right_label">āĻĄāĻžāύ āĻĒāĻĄ</string>
|
||||
<string name="pods_case_label">āĻā§āϏ</string>
|
||||
<string name="pods_case_status_open_label">āĻā§āϞāĻž</string>
|
||||
<string name="pods_case_status_closed_label">āĻŦāύā§āϧ</string>
|
||||
<string name="pods_connection_state_disconnected_label">āĻā§āύāĻ āĻĄāĻŋāĻāĻžāĻāϏā§āϰ āϏāĻžāĻĨā§ āϏāĻāϝā§āĻā§āϤ āύāϝāĻŧ</string>
|
||||
<string name="pods_connection_state_idle_label">āĻāĻāĻāĻŋ āĻĄāĻŋāĻāĻžāĻāϏā§āϰ āϏāĻžāĻĨā§ āϏāĻāϝā§āĻā§āϤ, āĻāĻŋāύā§āϤ⧠āύāĻŋāώā§āĻā§āϰāĻŋāϝāĻŧ</string>
|
||||
<string name="pods_connection_state_music_label">āĻŽāĻŋāĻāĻāĻŋāĻ āĻŽā§āĻĄā§</string>
|
||||
<string name="pods_connection_state_call_label">āĻāϞ āĻŽā§āĻĄā§</string>
|
||||
<string name="pods_connection_state_ringing_label">āϰāĻŋāĻ āĻšāĻā§āĻā§</string>
|
||||
<string name="pods_connection_state_hanging_up_label">āϏāĻāϝā§āĻ āĻŦāĻŋāĻā§āĻāĻŋāύā§āύ āĻšāĻā§āĻā§</string>
|
||||
<string name="pods_connection_state_unknown_label">āĻ
āĻāĻžāύāĻž āϏāĻāϝā§āĻ āϏā§āĻĨāĻŋāϤāĻŋ</string>
|
||||
<string name="pods_unknown_raw_data_label">āĻāĻžāĻāĻāĻž āĻĄā§āĻāĻž</string>
|
||||
<string name="pods_unknown_label">āĻ
āĻāĻžāύāĻž āĻĄāĻŋāĻāĻžāĻāϏ</string>
|
||||
<string name="pods_unknown_contact_dev">āĻāĻāĻŋ āĻāĻāĻāĻŋ āĻ
āĻāĻžāύāĻž āĻĄāĻŋāĻāĻžāĻāϏ, āϤāĻŦā§ āĻāĻāĻŋ āĻāĻāĻ āϧāϰāύā§āϰ āĻŦāĻžāϰā§āϤāĻž āĻŦāĻŋāύā§āϝāĻžāϏ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰāĻā§āĨ¤ āĻāϏā§āύ āĻāĻāĻŋāϰ āĻāύā§āϝ āϏāĻŽāϰā§āĻĨāύ āϝā§āĻ āĻāϰāĻŋ, āĻāĻŽāĻžāϰ āϏāĻžāĻĨā§ āϝā§āĻāĻžāϝā§āĻ āĻāϰā§āύ :)</string>
|
||||
<string name="pods_none_label_short">āĻā§āύāĻ āĻĄāĻŋāĻāĻžāĻāϏ āύā§āĻ</string>
|
||||
<string name="pods_charging_label">āĻāĻžāϰā§āĻ āĻšāĻā§āĻā§</string>
|
||||
<string name="pods_inear_label">āĻāĻžāύā§</string>
|
||||
<string name="pods_microphone_label">āĻŽāĻžāĻāĻā§āϰā§āĻĢā§āύ</string>
|
||||
<string name="pods_yours">āĻāĻĒāύāĻžāϰ</string>
|
||||
<string name="headset_being_worn_label">āĻĒāϰāĻŋāϧāĻžāύ āĻāϰāĻž āĻšāĻā§āĻā§</string>
|
||||
<string name="headset_not_being_worn_label">āĻĒāϰāĻŋāϧāĻžāύ āĻāϰāĻž āĻšāϝāĻŧāύāĻŋ</string>
|
||||
<string name="pods_case_unknown_state">āĻ
āĻāĻžāύāĻž āϏā§āĻĨāĻŋāϤāĻŋ</string>
|
||||
|
||||
<string name="last_seen_x">āĻļā§āώ āĻĻā§āĻāĻž: %s</string>
|
||||
<string name="first_seen_x">āĻĒā§āϰāĻĨāĻŽ āĻĻā§āĻāĻž: %s</string>
|
||||
<string name="permission_post_notifications_label">āĻŦāĻŋāĻā§āĻāĻĒā§āϤāĻŋ āĻĻā§āĻāĻžāύ</string>
|
||||
<string name="permission_post_notifications_description">CAPod-āĻā§ āĻāĻĒāύāĻžāϰ AirPods āϏāĻŽā§āĻĒāϰā§āĻā§ āĻŦāĻŋāĻā§āĻāĻĒā§āϤāĻŋ āĻĻā§āĻāĻžāϤ⧠āĻ
āύā§āĻŽāϤāĻŋ āĻĻāĻŋāύ, āϝā§āĻŽāύ āϏāĻāϝā§āĻā§āϤ āĻĨāĻžāĻāĻžāĻāĻžāϞā§āύ āϤāĻžāĻĻā§āϰ āĻŦāϰā§āϤāĻŽāĻžāύ āϏā§āĻĨāĻŋāϤāĻŋāĨ¤</string>
|
||||
</resources>
|
||||
|
||||
@@ -55,7 +55,10 @@
|
||||
<string name="pods_microphone_label">MicrÃ˛fon</string>
|
||||
<string name="pods_yours">Vostres</string>
|
||||
<string name="headset_being_worn_label">En Ãēs</string>
|
||||
<string name="headset_not_being_worn_label">No s\'utilitzen</string>
|
||||
<string name="pods_case_unknown_state">Estat desconegut</string>
|
||||
<string name="last_seen_x">Ãltim cop vist: %s</string>
|
||||
<string name="first_seen_x">Primera vegada vist: %s</string>
|
||||
<string name="permission_post_notifications_label">Mostra notificacions</string>
|
||||
<string name="permission_post_notifications_description">"Permet que el CAPod mostri notificacions sobre els vostres AirPods, p. ex. el seu estat actual mentre estan connectats."</string>
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user