mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
245
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,15 @@ 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 Max
|
||||
* Power Beats Pro
|
||||
* Power Beats 3
|
||||
* Power Beats 4
|
||||
* Beats Solo 3
|
||||
* Beats Studio 3
|
||||
* Beats X
|
||||
@@ -43,13 +49,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 +72,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 +81,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,18 @@ 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.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 +127,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,60 @@ 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.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 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")
|
||||
}
|
||||
}
|
||||
+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>
|
||||
|
||||
@@ -55,7 +55,10 @@
|
||||
<string name="pods_microphone_label">Mikrofon</string>
|
||||
<string name="pods_yours">Vaše</string>
|
||||
<string name="headset_being_worn_label">Používá se</string>
|
||||
<string name="headset_not_being_worn_label">Nepoužíváno</string>
|
||||
<string name="pods_case_unknown_state">Neznámý stav</string>
|
||||
<string name="last_seen_x">Naposledy spatřeno: %s</string>
|
||||
<string name="first_seen_x">Poprvé spatřeno: %s</string>
|
||||
<string name="permission_post_notifications_label">Zobrazit oznámení</string>
|
||||
<string name="permission_post_notifications_description">"Povolte aplikaci CAPod zobrazovat oznámení o sluchátkách AirPods, např. o jejich aktuálním stavu během připojení."</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