Compare commits

...
64 Commits
Author SHA1 Message Date
darken 753d311593 Tweak versioning and bump (1.3.14-rc0) 2022-08-24 11:56:42 +02:00
darken 5bae3d9931 Remove in-app email shortcut 2022-08-24 11:40:16 +02:00
darken a3495a8218 Update translations 2022-08-24 11:33:48 +02:00
Poussinou 5fa68f77a2 Update README.md 2022-07-31 11:13:51 +02:00
darken dbba2e9791 Another fastlane gplay upload fix. 2022-07-25 21:00:43 +02:00
darken 6207f26a68 Update fastlane to 2.208 2022-07-25 20:57:24 +02:00
darken 7fcd39822e Update fastlane to 2.208 2022-07-25 20:56:45 +02:00
darken d20f8a604f Fix CI issues with Google Play upload (part 2...) 2022-07-25 20:47:26 +02:00
darken a742646127 Fix CI github release apk search path. 2022-07-25 20:09:37 +02:00
darken f5078d5ef2 Tell fastlane to skip APK upload 2022-07-25 20:04:22 +02:00
darken 3784084abc Fix fastlane build arguments 2022-07-25 19:53:23 +02:00
darken 2f463b7735 Adjust description to pass markdown parsing 2022-07-25 19:42:15 +02:00
darken 7be5625d66 Fix gplay deploy script 2022-07-25 19:40:49 +02:00
darken aef14a9cd2 Create changelog file for v1.3.13(1031300) 2022-07-25 17:42:26 +02:00
darken 67648a04d3 Update translations 2022-07-25 17:36:29 +02:00
Matthias Urhahn 58345e3194 Update FUNDING.yml 2022-07-25 10:22:32 +02:00
darken 4cb2ad9a5a Adjust support item order 2022-07-25 01:29:18 +02:00
darken 27395d8e22 Remove email from readme 2022-07-25 01:29:07 +02:00
darken 230c5ea180 Adjust donation links in foss build 2022-07-25 01:22:07 +02:00
Matthias Urhahn 4d4370ddce Update README.md 2022-07-24 16:01:07 +02:00
darken 7e913e7847 Make bugsnag opt-out in gplay and opt-in in foss builds. 2022-07-24 16:00:38 +02:00
darken ec7871e4e9 Change issue tracker icon 2022-07-24 16:00:07 +02:00
darken cc5a45a14f Update repo links 2022-07-24 16:00:07 +02:00
Matthias Urhahn 5fff3457ba Update README.md 2022-07-24 15:03:19 +02:00
darken 6f5d66c1d9 Update README.md 2022-07-24 14:02:53 +02:00
darken d94795bf4f Update PRIVACY_POLICY.md 2022-07-24 13:58:17 +02:00
darken 009b71c42a Update PP 2022-07-24 13:43:47 +02:00
darken a10716d74e Update gitignore 2022-07-24 13:43:06 +02:00
darken b44bf5f401 Setup CI for testing, gplay release and foss release. 2022-07-24 13:38:52 +02:00
Matthias Urhahn e0af1838e2 Delete local.properties 2022-07-24 13:38:43 +02:00
darken 7d5502a34f Version bump (1.3.13) 2022-07-08 19:27:28 +02:00
darken 78ccddf87c Fix notification icon/text color to match light/dark theme. 2022-05-01 17:48:32 +02:00
darken df1bc48e84 Version bump (1.3.12) 2022-05-01 17:36:17 +02:00
darken 3b48d61571 Version bump (1.3.11) 2022-05-01 17:17:53 +02:00
darken 92d5e2a40d Updated translations 2022-05-01 17:13:42 +02:00
darken 234857c414 When enabling unfiltered BLE data, also enable "show all" to make debugging easier. 2022-05-01 16:42:17 +02:00
darken b1bbe785ea Fix debug log naming 2022-04-18 11:10:10 +02:00
darken 487fb49bff Update translations 2022-04-17 13:27:23 +02:00
darken b14b7fa926 Version bump (1.3.10) 2022-04-17 13:08:42 +02:00
darken 4cc1ab143d Change fake airpods pro name. 2022-04-17 13:07:27 +02:00
darken c23de67cd2 Fix ANR 2022-04-09 14:08:07 +02:00
darken e9bb4868c8 Fix NPE on some Android 9 ROMs due to bug in AOSP 2022-04-09 13:54:14 +02:00
darken ae6fd0eabd Updated translations 2022-04-09 11:43:21 +02:00
darken f4f73906bc Version bump (1.3.9) 2022-04-08 23:24:28 +02:00
darken c437decbbf Add VarunrAirPodsPro.kt 2022-04-08 23:18:35 +02:00
darken 1d36677ead Updated translations 2022-02-16 20:03:59 +01:00
darken 542b69ca92 Add acknowledgement entry for translator names. 2022-02-16 19:58:52 +01:00
darken 8547c9c396 Version bump (1.3.7) 2022-02-13 21:52:53 +01:00
darken 4e258e65c1 Fix paddings, cards less information were unnecessarily large. 2022-02-13 21:46:12 +01:00
darken 9f49b470fe Refactor interfaces to "single" and "dual" pods that each have one or more extra features. 2022-02-13 20:48:34 +01:00
darken 4a71cfe65a Version bump (1.3.7)? 2022-02-12 09:31:51 +01:00
darken d06959f964 Update fastlane scripts 2022-02-12 09:31:06 +01:00
darken 8c339ca2eb Shorten too long titles, replacing with EN for now 2022-02-12 09:30:48 +01:00
darken 3aab53566d Updated translations 2022-02-12 07:53:45 +01:00
darken 749cfed137 Remove unknown device locking, wasn't actually that useful. 2022-02-12 07:53:07 +01:00
darken ce25ad6711 Add support for Tws i99999 2022-02-12 07:45:47 +01:00
darken 568aeec0d5 Improve logging 2022-02-10 13:09:45 +01:00
darken 6b02fd66e6 Version bump (1.3.6) 2022-02-10 10:51:48 +01:00
darken 0e313004ac Updated translations 2022-02-10 10:39:39 +01:00
darken 270de8702b Improve source strings based on AO's feedback. 2022-02-10 10:27:07 +01:00
darken 324878d68d Add compatibility mode. 2022-02-10 10:19:07 +01:00
darken 9b39240016 Remove unused string 2022-02-10 08:11:24 +01:00
darken 4d6f185b58 Fix unfiltered mode only showing apple devices. 2022-02-10 07:01:10 +01:00
darken 5979081928 Version bump (1.3.5) 2022-02-04 06:59:57 +01:00
194 changed files with 3052 additions and 1435 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
# These are supported funding model platforms # These are supported funding model platforms
github:
- d4rken
custom: custom:
- "https://www.buymeacoffee.com/tydarken" - "https://www.buymeacoffee.com/tydarken"
@@ -1,4 +1,4 @@
name: Android CI name: Code tests & eval
on: on:
push: push:
@@ -7,13 +7,13 @@ on:
branches: [ main ] branches: [ main ]
jobs: jobs:
build: build-and-test:
name: Build and test name: Build and test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: set up JDK 11 - name: Set up JDK 11
uses: actions/setup-java@v2 uses: actions/setup-java@v2
with: with:
java-version: '11' java-version: '11'
@@ -22,7 +22,13 @@ jobs:
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew assembleDebug - name: Build FOSS variant
- name: Run tests run: ./gradlew assembleFossDebug
run: ./gradlew testGplayDebugUnitTest testFossDebugUnitTest - name: Test FOSS variant
run: ./gradlew testFossDebugUnitTest
- name: Build Google Play variant
run: ./gradlew assembleGplayDebug
- name: Test Google Play variant
run: ./gradlew testGplayDebugUnitTest
+153
View File
@@ -0,0 +1,153 @@
name: Tagged releases
on:
push:
tags:
- 'v*'
jobs:
release-github:
name: Create GitHub release
runs-on: ubuntu-latest
environment: foss-production
steps:
- name: Decode Keystore
env:
ENCODED_KEYSTORE: ${{ secrets.SIGNING_KEYSTORE_BASE64 }}
run: |
TMP_KEYSTORE_DIR_PATH="${RUNNER_TEMP}"/keystore
mkdir -p "${TMP_KEYSTORE_DIR_PATH}"
TMP_KEYSTORE_FILE_PATH="${TMP_KEYSTORE_DIR_PATH}"/keystore.jks
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
with:
fetch-depth: 0
- name: Get the version
id: tagger
uses: jimschubert/query-tag-action@v2
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
env:
VERSION: ${{ github.ref }}
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')"
run: ./gradlew assembleFossRelease
env:
VERSION: ${{ github.ref }}
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
with:
prerelease: true
tag_name: ${{ steps.tagger.outputs.tag }}
name: ${{ steps.tagger.outputs.tag }}
generate_release_notes: true
files: app/build/outputs/apk/foss/beta/*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release
if: "!contains(steps.tagger.outputs.tag, '-beta')"
uses: softprops/action-gh-release@v1
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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release-gplay:
name: Create Google Play release
runs-on: ubuntu-latest
environment: gplay-production
steps:
- name: Decode Keystore
env:
ENCODED_KEYSTORE: ${{ secrets.SIGNING_KEYSTORE_BASE64 }}
run: |
TMP_KEYSTORE_DIR_PATH="${RUNNER_TEMP}"/keystore
mkdir -p "${TMP_KEYSTORE_DIR_PATH}"
TMP_KEYSTORE_FILE_PATH="${TMP_KEYSTORE_DIR_PATH}"/keystore.jks
echo $ENCODED_KEYSTORE | base64 -di > "${TMP_KEYSTORE_FILE_PATH}"
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
- name: Decode Google Play service account key
env:
ENCODED_SERVICE_KEY: ${{ secrets.GPLAY_SERVICE_ACCOUNT_KEY_JSON_BASE64 }}
run: |
TMP_SERVICEKEY_DIR="${RUNNER_TEMP}"/gplaykey
mkdir -p "${TMP_SERVICEKEY_DIR}"
TMP_SERVICEKEY_FILE_PATH="${TMP_SERVICEKEY_DIR}"/service_account.json
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
with:
fetch-depth: 0
- name: Get the version
id: tagger
uses: jimschubert/query-tag-action@v2
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
with:
ruby-version: 2.7.6
bundler-cache: true
- name: Assemble beta and upload to Google Play
if: contains(steps.tagger.outputs.tag, '-beta')
run: bundle exec fastlane beta
env:
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 and upload to Google Play
if: "!contains(steps.tagger.outputs.tag, '-beta')"
run: bundle exec fastlane production
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }}
+14 -1
View File
@@ -1,3 +1,16 @@
.idea .idea
.gradle .gradle
build/ build/
/fastlane/report.xml
*.iml
local.properties
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
/.idea/**/*
!/.idea/codeStyles/
!/.idea/codeStyles/**/*
*.jks
.local/*
+3
View File
@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"
+218
View File
@@ -0,0 +1,218 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.5)
rexml
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
artifactory (3.0.15)
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)
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)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
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)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.7.6)
emoji_regex (3.2.3)
excon (0.92.4)
faraday (1.10.0)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
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-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 (~> 1.0)
fastimage (2.2.6)
fastlane (2.208.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
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (~> 2.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (>= 1.4.5, < 2.0.0)
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)
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)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
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-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)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.1)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.2.0)
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)
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)
multi_json (1.15.0)
multipart-post (2.0.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.1.1)
os (1.1.4)
plist (3.6.0)
public_suffix (4.0.7)
rake (13.0.6)
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)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.17.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.8)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.1)
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)
word_wrap (1.0.0)
xcodeproj (1.22.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)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
x86_64-linux
DEPENDENCIES
fastlane
BUNDLED WITH
2.2.8
+28 -10
View File
@@ -1,24 +1,42 @@
# Privacy policy for the app `CAPod` # Privacy policy
This is the privacy policy for the Android app "CAPod - Companion for AirPods".
* I do not collect personal information ## Preamble
* I do not sell, monetize or otherwise misappropriate any collected data. CAPod respects your privacy.
Anonymous device information may be collected in the event of a crash (see [Automatic crash reports](#automatic-crash-reports)). I do not collect, share or sell personal information.
Send a [quick mail](mailto:support@darken.eu) if you have questions.
My underlying privacy principle is the [Golden Rule](https://en.wikipedia.org/wiki/Golden_Rule). My underlying privacy principle is the [Golden Rule](https://en.wikipedia.org/wiki/Golden_Rule).
Send a [quick mail](mailto:support@darken.eu) if you have further questions. ## Location data
CAPod does not collect, share or sell location data.
Location permissions are required to receive Bluetooth Low Energy (BLE) data and ebale its core functionality.
The permission "access fine location" (`ACCESS_FINE_LOCATION`) and "access coarse location" (`ACCESS_COARSE_LOCATION`) are required to receive Bluetooth Low Energy data on Android 11 and lower.
On Android 12+ the newer and more fine grained `BLUETOOTH_SCAN` permission is used instead.
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.
### 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.
## Automatic crash reports ## Automatic crash reports
The app uses "Bugsnag" for automatic crash reports: Anonymous device information may be collected in the event of an app crash or error.
To do this the app uses the service "Bugsnag":
https://www.bugsnag.com/ https://www.bugsnag.com/
Bugsnags privacy policy can be found here: Bugsnag's privacy policy can be found here:
https://docs.bugsnag.com/legal/privacy-policy/ https://docs.bugsnag.com/legal/privacy-policy/
Crash reports may contain device and app related information. Crash reports may contain device and app related information, e.g. your phone model, Android version and app version.
You can disable automatic crash reports in the app's settings. You can disable automatic reports in the app's settings.
+13 -10
View File
@@ -1,9 +1,10 @@
<img src="https://github.com/d4rken/capod/raw/main/.assets/banner.png" width="400"> <img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400">
# Companion App for AirPods (CAPod) # Companion App for AirPods (CAPod)
[![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases)](https://github.com/d4rken-org/capod/releases/latest)
![Build](https://github.com/d4rken/capod/actions/workflows/android.yml/badge.svg) [![Code tests & eval](https://github.com/d4rken-org/capod/actions/workflows/code-checks.yml/badge.svg)](https://github.com/d4rken/capod/actions/workflows/code-checks.yml)
[![Github All Releases](https://img.shields.io/github/downloads/d4rken/capod/total.svg)](https://github.com/d4rken/capod/releases/latest) [![Crowdin](https://badges.crowdin.net/capod/localized.svg)](https://crowdin.com/project/capod)
[![Github All Releases](https://img.shields.io/github/downloads/d4rken-org/capod/total.svg)](https://github.com/d4rken-org/capod/releases/latest)
A companion app that adds support for AirPod specific features to Android: A companion app that adds support for AirPod specific features to Android:
@@ -19,8 +20,9 @@ CAPod is ad-free. Some additional features require an in-app purchase.
Currently supported models: Currently supported models:
* AirPods Gen1 * AirPods 1. Generation
* AirPods Gen2 * AirPods 2. Generation
* AirPods 3. Generation
* AirPods Pro * AirPods Pro
* AirPods Max * AirPods Max
* Power Beats Pro * Power Beats Pro
@@ -33,22 +35,23 @@ Currently supported models:
## Download ## Download
* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) * [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* [GitHub](https://github.com/d4rken/capod/releases/latest) * [GitHub](https://github.com/d4rken-org/capod/releases/latest)
* [IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.darken.capod)
## Support the project ## Support the project
* Buy the CAPod Pro In-App purchase on [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) * Buy the CAPod Pro In-App purchase on [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* [Sponsor development](https://github.com/sponsors/d4rken) on GitHub
* Help translate CAPod [on Crowdin](https://crowdin.com/project/capod) * Help translate CAPod [on Crowdin](https://crowdin.com/project/capod)
* [Buy me a coffee](https://www.buymeacoffee.com/tydarken)
## Get help ## Get help
* [Github Issues](https://github.com/d4rken/capod/issues) * [Github Issues](https://github.com/d4rken-org/capod/issues)
* [Discord](https://discord.gg/vHubYPp) * [Discord](https://discord.gg/vHubYPp)
## Screenshots ## Screenshots
<img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/4.png" width="200"> <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">
## Thanks to ## Thanks to
+57 -28
View File
@@ -16,10 +16,6 @@ android {
compileSdkVersion buildConfig.compileSdk compileSdkVersion buildConfig.compileSdk
Properties bugsnagProps = new Properties()
def bugsnagPropsFile = new File(System.properties['user.home'], ".appconfig/${packageName}/bugsnag.properties")
if (bugsnagPropsFile.canRead()) bugsnagProps.load(new FileInputStream(bugsnagPropsFile))
defaultConfig { defaultConfig {
applicationId "${packageName}" applicationId "${packageName}"
@@ -38,22 +34,55 @@ android {
} }
signingConfigs { signingConfigs {
release {} releaseFoss {}
releaseGplay {}
} }
def signingPropFile = new File(System.properties['user.home'], ".appconfig/${packageName}/signing.properties")
if (signingPropFile.canRead()) { signingConfigs {
Properties signingProps = new Properties() releaseFoss {
signingProps.load(new FileInputStream(signingPropFile)) def signingFossPropFile = new File(System.properties['user.home'], ".appconfig/${packageName}/signing-foss.properties")
signingConfigs { Properties signingPropsFoss = new Properties()
release { if (signingFossPropFile.canRead()) signingPropsFoss.load(new FileInputStream(signingFossPropFile))
storeFile new File(signingProps['release.storePath']) String keyStorePathFoss = System.getenv("STORE_PATH") ?: signingPropsFoss["release.storePath"]
keyAlias signingProps['release.keyAlias'] File keyStoreFoss = keyStorePathFoss ? new File(keyStorePathFoss) : null
storePassword signingProps['release.storePassword'] if (keyStoreFoss?.canRead()) {
keyPassword signingProps['release.keyPassword'] storeFile keyStoreFoss
storePassword System.getenv("STORE_PASSWORD") ?: signingPropsFoss['release.storePassword']
keyAlias System.getenv("KEY_ALIAS") ?: signingPropsFoss['release.keyAlias']
keyPassword System.getenv("KEY_PASSWORD") ?: signingPropsFoss['release.keyPassword']
}
}
releaseGplay {
def signingGplayPropFile = new File(System.properties['user.home'], ".appconfig/${packageName}/signing-gplay.properties")
Properties signingPropsGplay = new Properties()
if (signingGplayPropFile.canRead()) signingPropsGplay.load(new FileInputStream(signingGplayPropFile))
String keyStorePathGplay = System.getenv("STORE_PATH") ?: signingPropsGplay["release.storePath"]
File keyStoreGplay = keyStorePathGplay ? new File(keyStorePathGplay) : null
if (keyStoreGplay?.canRead()) {
storeFile keyStoreGplay
storePassword System.getenv("STORE_PASSWORD") ?: signingPropsGplay['release.storePassword']
keyAlias System.getenv("KEY_ALIAS") ?: signingPropsGplay['release.keyAlias']
keyPassword System.getenv("KEY_PASSWORD") ?: signingPropsGplay['release.keyPassword']
} }
} }
} }
flavorDimensions "version"
productFlavors {
foss {
signingConfig signingConfigs.releaseFoss
}
gplay {
signingConfig signingConfigs.releaseGplay
}
}
Properties bugsnagProps = new Properties()
def bugsnagPropsFile = new File(System.properties['user.home'], ".appconfig/${packageName}/bugsnag.properties")
if (bugsnagPropsFile.canRead()) bugsnagProps.load(new FileInputStream(bugsnagPropsFile))
String bugSnagApiKey = System.getenv("BUGSNAG_API_KEY") ?: bugsnagProps.getProperty("bugsnag.apikey", "")
buildTypes { buildTypes {
def proguardRulesRelease = fileTree(dir: "../proguard", include: ["*.pro"]).asList().toArray() def proguardRulesRelease = fileTree(dir: "../proguard", include: ["*.pro"]).asList().toArray()
debug { debug {
@@ -62,10 +91,9 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
proguardFiles proguardRulesRelease proguardFiles proguardRulesRelease
proguardFiles 'proguard-rules-debug.pro' proguardFiles 'proguard-rules-debug.pro'
manifestPlaceholders = [bugsnagApiKey: bugsnagProps.getProperty("bugsnag.apikey", "")] manifestPlaceholders = [bugsnagApiKey: bugSnagApiKey]
} }
release { beta {
signingConfig signingConfigs.release
lintOptions { lintOptions {
abortOnError true abortOnError true
fatal 'StopShip' fatal 'StopShip'
@@ -74,17 +102,18 @@ android {
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
proguardFiles proguardRulesRelease proguardFiles proguardRulesRelease
manifestPlaceholders = [bugsnagApiKey: bugsnagProps.getProperty("bugsnag.apikey", "")] manifestPlaceholders = [bugsnagApiKey: bugSnagApiKey]
} }
} release {
lintOptions {
flavorDimensions "version" abortOnError true
productFlavors { fatal 'StopShip'
gplay { }
minifyEnabled true
} shrinkResources true
foss { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
proguardFiles proguardRulesRelease
manifestPlaceholders = [bugsnagApiKey: bugSnagApiKey]
} }
} }
@@ -40,7 +40,7 @@ class UpgradeControlFoss @Inject constructor(
upgradedAt = Instant.now(), upgradedAt = Instant.now(),
reason = FossUpgrade.Reason.DONATED reason = FossUpgrade.Reason.DONATED
) )
webpageTool.open("https://github.com/d4rken/capod") webpageTool.open("https://github.com/d4rken-org/capod#support-the-project")
Toast.makeText(activity, R.string.general_thank_you_label, Toast.LENGTH_SHORT).show() Toast.makeText(activity, R.string.general_thank_you_label, Toast.LENGTH_SHORT).show()
} }
setNegativeButton(R.string.foss_upgrade_alreadydonated_label) { _, _ -> setNegativeButton(R.string.foss_upgrade_alreadydonated_label) { _, _ ->
+5 -1
View File
@@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="foss_upgrade_donate_label">Ахвяраваць</string>
<string name="foss_upgrade_alreadydonated_label">Я ўжо ахвяраваў</string>
<string name="foss_upgrade_no_money_label" comment="Can't be too long otherwise the dialog ellipsizes it. foss_upgrade_no_money_label" maxLength="35">Я патраціў усе грошы на AirPods</string>
</resources>
+1 -1
View File
@@ -2,5 +2,5 @@
<resources> <resources>
<string name="foss_upgrade_donate_label">Spenden</string> <string name="foss_upgrade_donate_label">Spenden</string>
<string name="foss_upgrade_alreadydonated_label">Ich habe schon gespendet</string> <string name="foss_upgrade_alreadydonated_label">Ich habe schon gespendet</string>
<string name="foss_upgrade_no_money_label">Ich gebe mein ganzes Geld für AirPods aus</string> <string name="foss_upgrade_no_money_label" comment="Can't be too long otherwise the dialog ellipsizes it. foss_upgrade_no_money_label" maxLength="35">Hab alles für AirPods ausgegeben</string>
</resources> </resources>
+5 -1
View File
@@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="foss_upgrade_donate_label">Donar</string>
<string name="foss_upgrade_alreadydonated_label">Ya doné</string>
<string name="foss_upgrade_no_money_label">Gasto todo mi dinero en AirPods</string>
</resources>
+5 -1
View File
@@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="foss_upgrade_donate_label">दान करना</string>
<string name="foss_upgrade_alreadydonated_label">मैंने पहले ही दान कर दिया है</string>
<string name="foss_upgrade_no_money_label">मैं अपना सारा पैसा AirPods पर खर्च करता हूं</string>
</resources>
+5 -1
View File
@@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="foss_upgrade_donate_label">Dona</string>
<string name="foss_upgrade_alreadydonated_label">Ho già donato</string>
<string name="foss_upgrade_no_money_label">Spendo tutti i miei soldi per gli AirPods</string>
</resources>
+5 -1
View File
@@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="foss_upgrade_donate_label">Derma</string>
<string name="foss_upgrade_alreadydonated_label">Saya sudah menderma</string>
<string name="foss_upgrade_no_money_label" comment="Can't be too long otherwise the dialog ellipsizes it. foss_upgrade_no_money_label" maxLength="35">Saya belanjakan semua wang saya untuk AirPods</string>
</resources>
+5 -1
View File
@@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="foss_upgrade_donate_label">Doner</string>
<string name="foss_upgrade_alreadydonated_label">Jeg har allerede donert</string>
<string name="foss_upgrade_no_money_label">Jeg bruker alle pengene mine på AirPods</string>
</resources>
+5 -1
View File
@@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="foss_upgrade_donate_label">Bağış</string>
<string name="foss_upgrade_alreadydonated_label">Ben zaten bağışladım</string>
<string name="foss_upgrade_no_money_label">Tüm paramı AirPod\'lara harcıyorum</string>
</resources>
+1 -1
View File
@@ -2,5 +2,5 @@
<resources> <resources>
<string name="foss_upgrade_donate_label">Quyên tặng</string> <string name="foss_upgrade_donate_label">Quyên tặng</string>
<string name="foss_upgrade_alreadydonated_label">Tôi đã quyên góp</string> <string name="foss_upgrade_alreadydonated_label">Tôi đã quyên góp</string>
<string name="foss_upgrade_no_money_label">Tôi tiêu hết tiền cho AirPods</string> <string name="foss_upgrade_no_money_label" comment="Can't be too long otherwise the dialog ellipsizes it. foss_upgrade_no_money_label" maxLength="35">Tôi tiêu hết tiền cho AirPods</string>
</resources> </resources>
+1 -1
View File
@@ -2,5 +2,5 @@
<resources> <resources>
<string name="foss_upgrade_donate_label">抖內</string> <string name="foss_upgrade_donate_label">抖內</string>
<string name="foss_upgrade_alreadydonated_label">我已經抖內了</string> <string name="foss_upgrade_alreadydonated_label">我已經抖內了</string>
<string name="foss_upgrade_no_money_label">我已經為 AirPods 傾家蕩產了</string> <string name="foss_upgrade_no_money_label" comment="Can't be too long otherwise the dialog ellipsizes it. foss_upgrade_no_money_label" maxLength="35">我已經為 AirPods 傾家蕩產了</string>
</resources> </resources>
+4 -1
View File
@@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="upgrades_gplay_unavailable_error">Службы Google Play недаступныя.</string>
<string name="upgrades_no_purchases_found_check_account">Пакупак не знойдзена. Вы выкарыстоўваеце правільны ўліковы запіс?</string>
</resources>
+4 -1
View File
@@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="upgrades_gplay_unavailable_error">Los servicios de Google Play no están disponibles.</string>
<string name="upgrades_no_purchases_found_check_account">No se han encontrado compras. ¿Estás usando la cuenta correcta?</string>
</resources>
+4 -1
View File
@@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="upgrades_gplay_unavailable_error">Google Play सेवाएं अनुपलब्ध हैं।</string>
<string name="upgrades_no_purchases_found_check_account">कोई खरीदारी नहीं मिली. क्या आप सही खाते का उपयोग कर रहे हैं?</string>
</resources>
+4 -1
View File
@@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="upgrades_gplay_unavailable_error">I servizi di Google Play non sono disponibili.</string>
<string name="upgrades_no_purchases_found_check_account">Nessun acquisto trovato. Stai usando l\'account giusto?</string>
</resources>
+4 -1
View File
@@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="upgrades_gplay_unavailable_error">Perkhidmatan Google Play tidak tersedia.</string>
<string name="upgrades_no_purchases_found_check_account">Tiada pembelian ditemui. Adakah anda menggunakan akaun yang betul?</string>
</resources>
+4 -1
View File
@@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="upgrades_gplay_unavailable_error">Google Play-tjenester er utilgjengelige.</string>
<string name="upgrades_no_purchases_found_check_account">Ingen kjøp funnet. Bruker du riktig konto?</string>
</resources>
+4 -1
View File
@@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="upgrades_gplay_unavailable_error">Google Play hizmetleri kullanılamıyor.</string>
<string name="upgrades_no_purchases_found_check_account">Satın alma bulunamadı. Doğru hesabı mı kullanıyorsunuz?</string>
</resources>
@@ -6,10 +6,34 @@ import eu.darken.capod.BuildConfig
// Can't be const because that prevents them from being mocked in tests // Can't be const because that prevents them from being mocked in tests
@Suppress("MayBeConstant") @Suppress("MayBeConstant")
object BuildConfigWrap { object BuildConfigWrap {
val FLAVOR: String = BuildConfig.FLAVOR
val BUILD_TYPE: String = BuildConfig.BUILD_TYPE
val DEBUG: Boolean = BuildConfig.DEBUG val DEBUG: Boolean = BuildConfig.DEBUG
val BUILD_TYPE: BuildType = when (val typ = BuildConfig.BUILD_TYPE) {
"debug" -> BuildType.DEV
"beta" -> BuildType.BETA
"release" -> BuildType.RELEASE
else -> throw IllegalArgumentException("Unknown buildtype: $typ")
}
enum class BuildType {
DEV,
BETA,
RELEASE,
;
}
val FLAVOR: Flavor = when (val flav = BuildConfig.FLAVOR) {
"gplay" -> Flavor.GPLAY
"foss" -> Flavor.FOSS
else -> throw IllegalStateException("Unknown flavor: $flav")
}
enum class Flavor {
GPLAY,
FOSS,
;
}
val APPLICATION_ID = BuildConfig.APPLICATION_ID val APPLICATION_ID = BuildConfig.APPLICATION_ID
val VERSION_CODE: Long = BuildConfig.VERSION_CODE.toLong() val VERSION_CODE: Long = BuildConfig.VERSION_CODE.toLong()
@@ -18,4 +42,5 @@ object BuildConfigWrap {
val VERSION_DESCRIPTION_LONG: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA] ${FLAVOR}_$BUILD_TYPE" 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" val VERSION_DESCRIPTION_SHORT: String = "v$VERSION_NAME [$GIT_SHA] $FLAVOR"
val VERSION_DESCRIPTION_TINY: String = "v$VERSION_NAME"
} }
@@ -1,5 +1,5 @@
package eu.darken.capod.common package eu.darken.capod.common
object PrivacyPolicy { object PrivacyPolicy {
const val URL = "https://raw.githubusercontent.com/d4rken/capod-public/main/PRIVACY_POLICY.md" const val URL = "https://raw.githubusercontent.com/d4rken-org/capod/main/PRIVACY_POLICY.md"
} }
@@ -31,8 +31,21 @@ class BleScanner @Inject constructor(
@SuppressLint("MissingPermission") fun scan( @SuppressLint("MissingPermission") fun scan(
filters: Set<ScanFilter>, filters: Set<ScanFilter>,
scannerMode: ScannerMode, scannerMode: ScannerMode,
compatMode: Boolean,
): Flow<List<BleScanResult>> = callbackFlow { ): Flow<List<BleScanResult>> = callbackFlow {
log(TAG, VERBOSE) { "scan(filters=$filters, scannerMode=$scannerMode, compatMode=$compatMode)" }
if (compatMode) log(TAG, WARN) { "Using compatibilityMode!" }
val adapter = bluetoothManager.adapter val adapter = bluetoothManager.adapter
val supportsOffloadFiltering = adapter.isOffloadedFilteringSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" }
} && !compatMode
val supportsOffloadBatching = adapter.isOffloadedScanBatchingSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" }
} && !compatMode
val scanner = bluetoothManager.scanner val scanner = bluetoothManager.scanner
val callback = object : ScanCallback() { val callback = object : ScanCallback() {
@@ -43,7 +56,17 @@ class BleScanner @Inject constructor(
lastScanAt = System.currentTimeMillis() lastScanAt = System.currentTimeMillis()
"onScanResult(delay=${delay}ms, callbackType=$callbackType, result=$result)" "onScanResult(delay=${delay}ms, callbackType=$callbackType, result=$result)"
} }
trySend(listOf(BleScanResult.fromScanResult(result))) val toSend = if (
supportsOffloadFiltering
|| filters.isEmpty()
|| filters.any { it.matchesSafe(result) }
) {
listOf(BleScanResult.fromScanResult(result))
} else {
log(TAG, VERBOSE) { "Manual filtering: No match for $result" }
emptyList()
}
trySend(toSend)
} }
override fun onBatchScanResults(results: MutableList<ScanResult>) { override fun onBatchScanResults(results: MutableList<ScanResult>) {
@@ -52,7 +75,19 @@ class BleScanner @Inject constructor(
lastScanAt = System.currentTimeMillis() lastScanAt = System.currentTimeMillis()
"onBatchScanResults(delay=${delay}ms, results=$results)" "onBatchScanResults(delay=${delay}ms, results=$results)"
} }
trySend(results.map { BleScanResult.fromScanResult(it) })
val toSend = results
.filter { result ->
val passed = when {
supportsOffloadFiltering -> true
filters.isEmpty() -> true
else -> filters.any { it.matches(result) }
}
if (!passed) log(TAG, VERBOSE) { "Manually filtered $result" }
passed
}
.map { BleScanResult.fromScanResult(it) }
trySend(toSend)
} }
override fun onScanFailed(errorCode: Int) { override fun onScanFailed(errorCode: Int) {
@@ -60,6 +95,7 @@ class BleScanner @Inject constructor(
} }
} }
val settings = ScanSettings.Builder().apply { val settings = ScanSettings.Builder().apply {
setScanMode( setScanMode(
when (scannerMode) { when (scannerMode) {
@@ -68,20 +104,24 @@ class BleScanner @Inject constructor(
ScannerMode.LOW_LATENCY -> ScanSettings.SCAN_MODE_LOW_LATENCY ScannerMode.LOW_LATENCY -> ScanSettings.SCAN_MODE_LOW_LATENCY
} }
) )
if (adapter.isOffloadedScanBatchingSupported) { if (supportsOffloadBatching) {
when (scannerMode) { setReportDelay(
ScannerMode.LOW_POWER -> setReportDelay(2000) when (scannerMode) {
ScannerMode.BALANCED -> setReportDelay(1000) ScannerMode.LOW_POWER -> 2000L
ScannerMode.LOW_LATENCY -> setReportDelay(500) ScannerMode.BALANCED -> 1000L
} ScannerMode.LOW_LATENCY -> 500L
} else { }
log(TAG, WARN) { "isOffloadedScanBatchingSupported=false" } )
} }
}.build() }.build()
log(TAG, VERBOSE) { "Settings created for offloaded filtering: $settings" }
val flushJob = launch { val flushJob = launch {
log(TAG) { "Flush job launched" }
while (isActive) { while (isActive) {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12 // Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
log(TAG, VERBOSE) { "Flushing scan results." }
adapter.bluetoothLeScanner.flushPendingScanResults(callback) adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) { when (scannerMode) {
ScannerMode.LOW_POWER -> break ScannerMode.LOW_POWER -> break
@@ -91,19 +131,17 @@ class BleScanner @Inject constructor(
} }
} }
if (adapter.isOffloadedFilteringSupported) { scanner.startScan(
scanner.startScan(filters.toList(), settings, callback) if (supportsOffloadFiltering) filters.toList() else listOf(ScanFilter.Builder().build()),
log(TAG, VERBOSE) { "BleScanner started (filters=$filters, settings=$settings)" } settings,
} else { callback
log(TAG, WARN) { "isOffloadedFilteringSupported=false" } )
scanner.startScan(callback) log(TAG) { "BleScanner started (filters=$filters, settings=$settings)" }
log(TAG, VERBOSE) { "BleScanner started" }
}
awaitClose { awaitClose {
flushJob.cancel() flushJob.cancel()
scanner.stopScan(callback) scanner.stopScan(callback)
log(TAG, INFO) { "BleScanner stopped" } log(TAG) { "BleScanner stopped" }
} }
} }
.map { fakeBleData.maybeAddfakeData(it) } .map { fakeBleData.maybeAddfakeData(it) }
@@ -1,8 +0,0 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothDevice
import android.os.ParcelUuid
fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
return uuids?.contains(uuid) ?: false
}
@@ -0,0 +1,26 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.os.ParcelUuid
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
return uuids?.contains(uuid) ?: false
}
/**
* java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.Object android.util.SparseArray.get(int)' on a null object reference
* at android.bluetooth.le.ScanRecord.getManufacturerSpecificData(ScanRecord.java:118)
* at android.bluetooth.le.ScanFilter.matches(ScanFilter.java:369)
* ZenFone Max Pro M1 (ZB602KL) (WW) / Max Pro M1 (ZB601KL) (IN) (ZB602KL), Android 9, PKQ1.WW_Phone-16.2017.2009.087-20200826
* Intel Gemini Lake Chromebook (octopus), Android 9, R99-14469.59.0 release-keys
*/
fun ScanFilter.matchesSafe(scanResult: ScanResult): Boolean = try {
matches(scanResult)
} catch (e: NullPointerException) {
log { "AOSP error: ${e.asLog()}" }
false
}
@@ -75,6 +75,19 @@ class FakeBleData @Inject constructor(
fakeDevices.add(this) fakeDevices.add(this)
} }
} }
// Tws i99999
BleScanResult(
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())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// Unknown Device // Unknown Device
BleScanResult( BleScanResult(
address = "6E:9E:D1:49:D2:6D", address = "6E:9E:D1:49:D2:6D",
@@ -28,12 +28,12 @@ class AutoReporting @Inject constructor(
) { ) {
fun setup() { fun setup() {
val isEnabled = debugSettings.isAutoReportEnabled.value val isEnabled = debugSettings.isAutoReportingEnabled.value
log(TAG) { "setup(): isEnabled=$isEnabled" } log(TAG) { "setup(): isEnabled=$isEnabled" }
try { try {
val bugsnagConfig = Configuration.load(context).apply { val bugsnagConfig = Configuration.load(context).apply {
if (debugSettings.isAutoReportEnabled.value) { if (debugSettings.isAutoReportingEnabled.value) {
Logging.install(bugsnagLogger.get()) Logging.install(bugsnagLogger.get())
setUser(installId.id, null, null) setUser(installId.id, null, null)
autoTrackSessions = true autoTrackSessions = true
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceDataStore
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.preferences.PreferenceStoreMapper import eu.darken.capod.common.preferences.PreferenceStoreMapper
import eu.darken.capod.common.preferences.Settings import eu.darken.capod.common.preferences.Settings
import eu.darken.capod.common.preferences.createFlowPreference import eu.darken.capod.common.preferences.createFlowPreference
@@ -17,8 +18,11 @@ class DebugSettings @Inject constructor(
override val preferences: SharedPreferences = context.getSharedPreferences("settings_debug", Context.MODE_PRIVATE) override val preferences: SharedPreferences = context.getSharedPreferences("settings_debug", Context.MODE_PRIVATE)
val isAutoReportEnabled = preferences.createFlowPreference("debug.bugreport.automatic.enabled", true) val isAutoReportingEnabled = preferences.createFlowPreference(
key = "debug.bugreport.automatic.enabled",
// Reporting is opt-out for gplay, and opt-in for github builds
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
)
val isDebugModeEnabled = preferences.createFlowPreference("debug.mode.enabled", false) val isDebugModeEnabled = preferences.createFlowPreference("debug.mode.enabled", false)
val showFakeData = preferences.createFlowPreference("debug.fakedata.enabled", false) val showFakeData = preferences.createFlowPreference("debug.fakedata.enabled", false)
@@ -32,7 +32,7 @@ class BugsnagErrorHandler @Inject constructor(
context.tryFormattedSignature()?.let { event.addMetadata(tab, "signatures", it) } context.tryFormattedSignature()?.let { event.addMetadata(tab, "signatures", it) }
} }
return debugSettings.isAutoReportEnabled.value && !BuildConfigWrap.DEBUG return debugSettings.isAutoReportingEnabled.value && !BuildConfigWrap.DEBUG
} }
companion object { companion object {
@@ -90,7 +90,7 @@ class RecorderModule @Inject constructor(
private fun createRecordingFilePath() = File( private fun createRecordingFilePath() = File(
File(context.cacheDir, "debug/logs"), File(context.cacheDir, "debug/logs"),
"bb_logfile_${System.currentTimeMillis()}.log" "capod_logfile_${System.currentTimeMillis()}.log"
) )
suspend fun startRecorder(): File { suspend fun startRecorder(): File {
@@ -123,6 +123,6 @@ class RecorderModule @Inject constructor(
companion object { companion object {
internal val TAG = logTag("Debug", "Log", "Recorder", "Module") internal val TAG = logTag("Debug", "Log", "Recorder", "Module")
private const val FORCE_FILE = "bb_force_debug_run" private const val FORCE_FILE = "capod_force_debug_run"
} }
} }
@@ -34,6 +34,11 @@ class GeneralSettings @Inject constructor(
moshi moshi
) )
val compatibilityMode = preferences.createFlowPreference(
"core.compatibility.enabled",
false
)
val showAll = preferences.createFlowPreference( val showAll = preferences.createFlowPreference(
"core.showall.enabled", "core.showall.enabled",
false false
@@ -58,9 +63,10 @@ class GeneralSettings @Inject constructor(
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper( override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
monitorMode, monitorMode,
scannerMode, scannerMode,
compatibilityMode,
showAll, showAll,
minimumSignalQuality, minimumSignalQuality,
mainDeviceAddress, mainDeviceAddress,
debugSettings.isAutoReportEnabled, debugSettings.isAutoReportingEnabled,
) )
} }
@@ -14,9 +14,8 @@ import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.main.ui.overview.cards.MissingMainDeviceVH import eu.darken.capod.main.ui.overview.cards.MissingMainDeviceVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.pods.BasicSingleApplePodsCardVH import eu.darken.capod.main.ui.overview.cards.pods.DualPodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.DualApplePodsCardVH import eu.darken.capod.main.ui.overview.cards.pods.SinglePodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.SingleApplePodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.UnknownPodDeviceCardVH import eu.darken.capod.main.ui.overview.cards.pods.UnknownPodDeviceCardVH
import javax.inject.Inject import javax.inject.Inject
@@ -29,9 +28,8 @@ class OverviewAdapter @Inject constructor() :
init { init {
modules.add(DataBinderMod(data)) modules.add(DataBinderMod(data))
modules.add(TypedVHCreatorMod({ data[it] is PermissionCardVH.Item }) { PermissionCardVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is PermissionCardVH.Item }) { PermissionCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is DualApplePodsCardVH.Item }) { DualApplePodsCardVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is DualPodsCardVH.Item }) { DualPodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is SingleApplePodsCardVH.Item }) { SingleApplePodsCardVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is SinglePodsCardVH.Item }) { SinglePodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is BasicSingleApplePodsCardVH.Item }) { BasicSingleApplePodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is MissingMainDeviceVH.Item }) { MissingMainDeviceVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is MissingMainDeviceVH.Item }) { MissingMainDeviceVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) })
@@ -10,6 +10,7 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.BuildConfig
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.colorString import eu.darken.capod.common.colorString
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
@@ -71,6 +72,7 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
} }
vm.listItems.observe2(ui) { vm.listItems.observe2(ui) {
if (BuildConfig.DEBUG) toolbar.subtitle = "${it.size} items"
adapter.update(it) adapter.update(it)
} }
@@ -20,23 +20,23 @@ import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.main.ui.overview.cards.MissingMainDeviceVH import eu.darken.capod.main.ui.overview.cards.MissingMainDeviceVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.pods.* import eu.darken.capod.main.ui.overview.cards.pods.DualPodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.SinglePodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.UnknownPodDeviceCardVH
import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.core.worker.MonitorControl import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import java.time.Instant import java.time.Instant
import java.util.*
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class OverviewFragmentVM @Inject constructor( class OverviewFragmentVM @Inject constructor(
handle: SavedStateHandle, @Suppress("UNUSED_PARAMETER") handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider, dispatcherProvider: DispatcherProvider,
private val monitorControl: MonitorControl, private val monitorControl: MonitorControl,
private val podMonitor: PodMonitor, private val podMonitor: PodMonitor,
@@ -74,7 +74,7 @@ class OverviewFragmentVM @Inject constructor(
monitorControl.startMonitor() monitorControl.startMonitor()
} }
} }
.map { Unit } .map { }
.asLiveData2() .asLiveData2()
val requestPermissionEvent = SingleLiveEvent<Permission>() val requestPermissionEvent = SingleLiveEvent<Permission>()
@@ -129,19 +129,13 @@ class OverviewFragmentVM @Inject constructor(
pods.map { pods.map {
val now = Instant.now() val now = Instant.now()
when (it) { when (it) {
is DualApplePods -> DualApplePodsCardVH.Item( is DualPodDevice -> DualPodsCardVH.Item(
now = now, now = now,
device = it, device = it,
showDebug = isDebugMode, showDebug = isDebugMode,
isMainPod = it == mainPod, isMainPod = it == mainPod,
) )
is SingleApplePods -> SingleApplePodsCardVH.Item( is SinglePodDevice -> SinglePodsCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = it == mainPod,
)
is BasicSingleApplePods -> BasicSingleApplePodsCardVH.Item(
now = now, now = now,
device = it, device = it,
showDebug = isDebugMode, showDebug = isDebugMode,
@@ -1,58 +0,0 @@
package eu.darken.capod.main.ui.overview.cards.pods
import android.graphics.Typeface
import android.view.ViewGroup
import androidx.core.view.isGone
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsAppleSingleBasicItemBinding
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant
class BasicSingleApplePodsCardVH(parent: ViewGroup) :
PodDeviceVH<BasicSingleApplePodsCardVH.Item, OverviewPodsAppleSingleBasicItemBinding>(
R.layout.overview_pods_apple_single_basic_item,
parent
) {
override val viewBinding = lazy { OverviewPodsAppleSingleBasicItemBinding.bind(itemView) }
override val onBindData = binding(payload = true) { item: Item ->
val device = item.device
name.apply {
text = device.getLabel(context)
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD)
else setTypeface(typeface, Typeface.NORMAL)
}
deviceIcon.setImageResource(device.iconRes)
lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = item.getReceptionText()
batteryLabel.text = device.getBatteryLevelHeadset(context)
batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent))
status.apply {
val sb = StringBuilder()
if (item.showDebug) {
sb.append("--- Debug ---")
sb.append("\n").append(device.rawDataHex)
}
text = sb
isGone = !item.showDebug
}
}
data class Item(
override val now: Instant,
override val device: BasicSingleApplePods,
override val showDebug: Boolean,
override val isMainPod: Boolean,
) : PodDeviceVH.Item
}
@@ -1,112 +0,0 @@
package eu.darken.capod.main.ui.overview.cards.pods
import android.graphics.Typeface
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsAppleDualItemBinding
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.DeviceColor
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import java.time.Duration
import java.time.Instant
class DualApplePodsCardVH(parent: ViewGroup) :
PodDeviceVH<DualApplePodsCardVH.Item, OverviewPodsAppleDualItemBinding>(
R.layout.overview_pods_apple_dual_item,
parent
) {
override val viewBinding = lazy { OverviewPodsAppleDualItemBinding.bind(itemView) }
override val onBindData = binding(payload = true) { item: Item ->
val device = item.device
name.apply {
val sb = StringBuilder(device.getLabel(context))
if (!listOf(DeviceColor.WHITE, DeviceColor.UNKNOWN).contains(device.deviceColor)) {
sb.append(" (${device.deviceColor.name})")
}
text = sb
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD)
else setTypeface(typeface, Typeface.NORMAL)
if (item.showDebug) {
append(" [${device.primaryPod.name}]")
}
}
deviceIcon.setImageResource(device.iconRes)
lastSeen.text = context.getString(R.string.last_seen_x, device.lastSeenFormatted(item.now))
firstSeen.text = context.getString(R.string.first_seen_x, device.firstSeenFormatted(item.now))
firstSeen.isGone = Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() < 1
reception.text = item.getReceptionText()
// Left Pod
device.apply {
podLeftBatteryIcon.setImageResource(getBatteryDrawable(batteryLeftPodPercent))
podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
podLeftChargingIcon.isInvisible = !isLeftPodCharging
podLeftChargingLabel.isInvisible = !isLeftPodCharging
podLeftMicrophoneIcon.isInvisible = !isLeftPodMicrophone
podLeftMicrophoneLabel.isInvisible = !isLeftPodMicrophone
podLeftWearIcon.isInvisible = !isLeftPodInEar
podLeftWearLabel.isInvisible = !isLeftPodInEar
}
// Right Pod
device.apply {
podRightBatteryIcon.setImageResource(getBatteryDrawable(batteryRightPodPercent))
podRightBatteryLabel.text = getBatteryLevelRightPod(context)
podRightChargingIcon.isInvisible = !isRightPodCharging
podRightChargingLabel.isInvisible = !isRightPodCharging
podRightMicrophoneIcon.isInvisible = !isRightPodMicrophone
podRightMicrophoneLabel.isInvisible = !isRightPodMicrophone
podRightWearIcon.isInvisible = !isRightPodInEar
podRightWearLabel.isInvisible = !isRightPodInEar
}
// Case
device.apply {
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
podCaseBatteryLabel.text = getBatteryLevelCase(context)
podCaseChargingIcon.isInvisible = !isCaseCharging
podCaseChargingLabel.isInvisible = !isCaseCharging
podCaseLidLabel.text = when (caseLidState) {
LidState.OPEN -> context.getString(R.string.pods_case_status_open_label)
LidState.CLOSED -> context.getString(R.string.pods_case_status_closed_label)
else -> context.getString(R.string.pods_case_unknown_state)
}
val hideInfo = !listOf(LidState.OPEN, LidState.CLOSED).contains(caseLidState)
podCaseLidIcon.isInvisible = hideInfo
podCaseLidLabel.isInvisible = hideInfo
}
status.apply {
val sb = StringBuilder(device.getConnectionStateLabel(context))
if (item.showDebug) {
sb.append("\n\n").append("---Debug---")
sb.append("\n").append(device.rawDataHex)
}
text = sb
}
}
data class Item(
override val now: Instant,
override val device: DualApplePods,
override val showDebug: Boolean,
override val isMainPod: Boolean,
) : PodDeviceVH.Item
}
@@ -0,0 +1,166 @@
package eu.darken.capod.main.ui.overview.cards.pods
import android.graphics.Typeface
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsDualItemBinding
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualAirPods.LidState
import java.time.Duration
import java.time.Instant
class DualPodsCardVH(parent: ViewGroup) :
PodDeviceVH<DualPodsCardVH.Item, OverviewPodsDualItemBinding>(
R.layout.overview_pods_dual_item,
parent
) {
override val viewBinding = lazy { OverviewPodsDualItemBinding.bind(itemView) }
override val onBindData = binding(payload = true) { item: Item ->
val device = item.device
name.apply {
val sb = StringBuilder(device.getLabel(context))
if (device is HasPodStyle && item.showDebug) {
val style = device.podStyle
sb.append(" (${style.getColor(context)})")
}
text = sb
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD)
else setTypeface(typeface, Typeface.NORMAL)
if (device is DualAirPods && item.showDebug) {
append(" [${device.primaryPod.name}]")
}
}
deviceIcon.setImageResource(device.iconRes)
lastSeen.text = context.getString(R.string.last_seen_x, device.lastSeenFormatted(item.now))
firstSeen.text = context.getString(R.string.first_seen_x, device.firstSeenFormatted(item.now))
firstSeen.isGone = Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() < 1
reception.text = item.getReceptionText()
// Pods battery state
device.apply {
podLeftBatteryIcon.setImageResource(getBatteryDrawable(batteryLeftPodPercent))
podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
podRightBatteryIcon.setImageResource(getBatteryDrawable(batteryRightPodPercent))
podRightBatteryLabel.text = getBatteryLevelRightPod(context)
}
// Pods charging state
device.apply {
if (this is HasChargeDetectionDual) {
podLeftChargingIcon.isInvisible = !isLeftPodCharging
podLeftChargingLabel.isInvisible = !isLeftPodCharging
podRightChargingIcon.isInvisible = !isRightPodCharging
podRightChargingLabel.isInvisible = !isRightPodCharging
} else {
podLeftChargingIcon.isGone = true
podLeftChargingLabel.isGone = true
podRightChargingIcon.isGone = true
podRightChargingLabel.isGone = true
}
}
// Microphone state
device.apply {
if (this is HasDualMicrophone) {
podLeftMicrophoneIcon.isInvisible = !isLeftPodMicrophone
podLeftMicrophoneLabel.isInvisible = !isLeftPodMicrophone
podRightMicrophoneIcon.isInvisible = !isRightPodMicrophone
podRightMicrophoneLabel.isInvisible = !isRightPodMicrophone
} else {
podLeftMicrophoneIcon.isGone = true
podLeftMicrophoneLabel.isGone = true
podRightMicrophoneIcon.isGone = true
podRightMicrophoneLabel.isGone = true
}
}
// Pods wear state
device.apply {
if (this is HasEarDetectionDual) {
podLeftWearIcon.isInvisible = !isLeftPodInEar
podLeftWearLabel.isInvisible = !isLeftPodInEar
podRightWearIcon.isInvisible = !isRightPodInEar
podRightWearLabel.isInvisible = !isRightPodInEar
} else {
podLeftWearIcon.isGone = true
podLeftWearLabel.isGone = true
podRightWearIcon.isGone = true
podRightWearLabel.isGone = true
}
}
// Case charge state
device.apply {
if (this is HasCase) {
podCaseBatteryIcon.isGone = false
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
podCaseBatteryLabel.text = getBatteryLevelCase(context)
podCaseChargingIcon.isInvisible = !isCaseCharging
podCaseChargingLabel.isInvisible = !isCaseCharging
} else {
podCaseBatteryIcon.isGone = true
podCaseBatteryLabel.isGone = true
podCaseChargingIcon.isGone = true
podCaseChargingLabel.isGone = true
}
}
// Case lid state
device.apply {
if (this is DualAirPods) {
podCaseLidLabel.text = when (caseLidState) {
LidState.OPEN -> context.getString(R.string.pods_case_status_open_label)
LidState.CLOSED -> context.getString(R.string.pods_case_status_closed_label)
else -> context.getString(R.string.pods_case_unknown_state)
}
val hideInfo = !listOf(LidState.OPEN, LidState.CLOSED).contains(caseLidState)
podCaseLidIcon.isInvisible = hideInfo
podCaseLidLabel.isInvisible = hideInfo
} else {
podCaseLidIcon.isGone = true
podCaseLidLabel.isGone = true
}
}
// Connection state
device.apply {
val sb = StringBuilder()
if (this is HasStateDetection) {
sb.append(state.getLabel(context))
}
if (item.showDebug) {
sb.append("\n\n").append("---Debug---")
sb.append("\n").append(rawDataHex)
}
status.text = sb
status.isGone = sb.isEmpty()
}
}
data class Item(
override val now: Instant,
override val device: DualPodDevice,
override val showDebug: Boolean,
override val isMainPod: Boolean,
) : PodDeviceVH.Item
}
@@ -1,65 +0,0 @@
package eu.darken.capod.main.ui.overview.cards.pods
import android.graphics.Typeface
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsAppleSingleItemBinding
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant
class SingleApplePodsCardVH(parent: ViewGroup) :
PodDeviceVH<SingleApplePodsCardVH.Item, OverviewPodsAppleSingleItemBinding>(
R.layout.overview_pods_apple_single_item,
parent
) {
override val viewBinding = lazy { OverviewPodsAppleSingleItemBinding.bind(itemView) }
override val onBindData = binding(payload = true) { item: Item ->
val device = item.device
name.apply {
text = device.getLabel(context)
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD)
else setTypeface(typeface, Typeface.NORMAL)
}
deviceIcon.setImageResource(device.iconRes)
lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = item.getReceptionText()
batteryLabel.text = device.getBatteryLevelHeadset(context)
batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent))
chargingIcon.isInvisible = device.isHeadsetBeingCharged
chargingLabel.isInvisible = device.isHeadsetBeingCharged
wearIcon.isInvisible = device.isHeadphonesBeingWorn
wearLabel.isInvisible = device.isHeadphonesBeingWorn
status.apply {
val sb = StringBuilder()
if (item.showDebug) {
sb.append("--- Debug ---")
sb.append("\n").append(device.rawDataHex)
}
text = sb
isGone = !item.showDebug
}
}
data class Item(
override val now: Instant,
override val device: SingleApplePods,
override val showDebug: Boolean,
override val isMainPod: Boolean,
) : PodDeviceVH.Item
}
@@ -0,0 +1,81 @@
package eu.darken.capod.main.ui.overview.cards.pods
import android.graphics.Typeface
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsSingleItemBinding
import eu.darken.capod.pods.core.*
import java.time.Instant
class SinglePodsCardVH(parent: ViewGroup) :
PodDeviceVH<SinglePodsCardVH.Item, OverviewPodsSingleItemBinding>(
R.layout.overview_pods_single_item,
parent
) {
override val viewBinding = lazy { OverviewPodsSingleItemBinding.bind(itemView) }
override val onBindData = binding(payload = true) { item: Item ->
val device = item.device
name.apply {
text = device.getLabel(context)
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD)
else setTypeface(typeface, Typeface.NORMAL)
}
deviceIcon.setImageResource(device.iconRes)
lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = item.getReceptionText()
// Battery level
device.apply {
batteryLabel.text = getBatteryLevelHeadset(context)
batteryIcon.setImageResource(getBatteryDrawable(batteryHeadsetPercent))
}
// Charge state
device.apply {
if (this is HasChargeDetection) {
chargingIcon.isInvisible = isHeadsetBeingCharged
chargingLabel.isInvisible = isHeadsetBeingCharged
} else {
chargingIcon.isGone = true
chargingLabel.isGone = true
}
}
// Has ear detection
device.apply {
if (this is HasEarDetection) {
wearIcon.isInvisible = isBeingWorn
wearLabel.isInvisible = isBeingWorn
} else {
wearIcon.isGone = true
wearLabel.isGone = true
}
}
status.apply {
val sb = StringBuilder()
if (item.showDebug) {
sb.append("--- Debug ---")
sb.append("\n").append(device.rawDataHex)
}
text = sb
isGone = !item.showDebug
}
}
data class Item(
override val now: Instant,
override val device: SinglePodDevice,
override val showDebug: Boolean,
override val isMainPod: Boolean,
) : PodDeviceVH.Item
}
@@ -68,7 +68,7 @@ class SettingsFragment : Fragment2(R.layout.settings_fragment),
} }
ui.toolbar.apply { ui.toolbar.apply {
subtitle = BuildConfigWrap.VERSION_DESCRIPTION_SHORT subtitle = BuildConfigWrap.VERSION_DESCRIPTION_TINY
setNavigationOnClickListener { requireActivity().onBackPressed() } setNavigationOnClickListener { requireActivity().onBackPressed() }
} }
@@ -34,6 +34,7 @@ class DebugSettingsFragment : PreferenceFragment2() {
vm.toggleRecorder() vm.toggleRecorder()
true true
} }
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
} }
@@ -3,10 +3,15 @@ package eu.darken.capod.main.ui.settings.general.debug
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.recording.core.RecorderModule import eu.darken.capod.common.debug.recording.core.RecorderModule
import eu.darken.capod.common.uix.ViewModel3 import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.main.core.GeneralSettings
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -14,10 +19,24 @@ class DebugSettingsFragmentVM @Inject constructor(
private val handle: SavedStateHandle, private val handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider, dispatcherProvider: DispatcherProvider,
private val recorderModule: RecorderModule, private val recorderModule: RecorderModule,
private val generalSettings: GeneralSettings,
private val debugSettings: DebugSettings,
) : ViewModel3(dispatcherProvider) { ) : ViewModel3(dispatcherProvider) {
val state = recorderModule.state.asLiveData2() val state = recorderModule.state.asLiveData2()
init {
debugSettings.showUnfiltered.flow
.distinctUntilChanged()
.onEach { showUnfiltered ->
if (showUnfiltered) {
log(TAG) { "Enabling 'show all' due to debug setting 'show unfiltered' enabled" }
generalSettings.showAll.value = true
}
}
.launchInViewModel()
}
fun toggleRecorder() = launch { fun toggleRecorder() = launch {
if (recorderModule.state.first().isRecording) { if (recorderModule.state.first().isRecording) {
recorderModule.stopRecorder() recorderModule.stopRecorder()
@@ -28,17 +28,12 @@ class SupportFragment : PreferenceFragment2() {
@Inject lateinit var clipboardHelper: ClipboardHelper @Inject lateinit var clipboardHelper: ClipboardHelper
private val installIdPref by lazy { findPreference<Preference>("support.installid")!! } private val installIdPref by lazy { findPreference<Preference>("support.installid")!! }
private val supportMailPref by lazy { findPreference<Preference>("support.email.darken")!! }
override fun onPreferencesCreated() { override fun onPreferencesCreated() {
installIdPref.setOnPreferenceClickListener { installIdPref.setOnPreferenceClickListener {
vm.copyInstallID() vm.copyInstallID()
true true
} }
supportMailPref.setOnPreferenceClickListener {
vm.sendSupportMail()
true
}
super.onPreferencesCreated() super.onPreferencesCreated()
} }
@@ -52,8 +47,6 @@ class SupportFragment : PreferenceFragment2() {
.show() .show()
} }
vm.emailEvent.observe2(this) { startActivity(it) }
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
} }
} }
@@ -1,11 +1,7 @@
package eu.darken.capod.main.ui.settings.support package eu.darken.capod.main.ui.settings.support
import android.content.Intent
import android.os.Build
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.EmailTool
import eu.darken.capod.common.InstallId import eu.darken.capod.common.InstallId
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.livedata.SingleLiveEvent import eu.darken.capod.common.livedata.SingleLiveEvent
@@ -15,34 +11,12 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SupportFragmentVM @Inject constructor( class SupportFragmentVM @Inject constructor(
private val handle: SavedStateHandle, private val handle: SavedStateHandle,
private val emailTool: EmailTool,
private val installId: InstallId,
private val dispatcherProvider: DispatcherProvider, private val dispatcherProvider: DispatcherProvider,
private val installId: InstallId,
) : ViewModel3(dispatcherProvider) { ) : ViewModel3(dispatcherProvider) {
val emailEvent = SingleLiveEvent<Intent>()
val clipboardEvent = SingleLiveEvent<String>() val clipboardEvent = SingleLiveEvent<String>()
fun sendSupportMail() = launch {
val bodyInfo = StringBuilder("\n\n\n")
bodyInfo.append("--- Infos for the developer ---\n")
bodyInfo.append("App version: ").append(BuildConfigWrap.VERSION_DESCRIPTION_LONG).append("\n")
bodyInfo.append("Device: ").append(Build.FINGERPRINT).append("\n")
bodyInfo.append("Install ID: ").append(installId.id).append("\n")
val email = EmailTool.Email(
receipients = listOf("support@darken.eu"),
subject = "[CAPod] Question/Suggestion/Request\n",
body = bodyInfo.toString()
)
emailEvent.postValue(emailTool.build(email))
}
fun copyInstallID() = launch { fun copyInstallID() = launch {
clipboardEvent.postValue(installId.id) clipboardEvent.postValue(installId.id)
} }
@@ -16,7 +16,6 @@ import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.ScannerMode import eu.darken.capod.main.core.ScannerMode
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -62,19 +61,23 @@ class PodMonitor @Inject constructor(
log(TAG) { "Bluetooth is enabled" } log(TAG) { "Bluetooth is enabled" }
combine( combine(
generalSettings.scannerMode.flow, generalSettings.scannerMode.flow,
generalSettings.compatibilityMode.flow,
debugSettings.showUnfiltered.flow debugSettings.showUnfiltered.flow
) { mode, unfiltered -> mode to unfiltered } ) { scannerMode, compatMode, unfiltered ->
.flatMapLatest { (mode, unfiltered) -> Triple(scannerMode, compatMode, unfiltered)
val filters = if (unfiltered) { }.flatMapLatest { (mode, compat, unfiltered) ->
setOf(getUnfilteredFilter()) log(TAG, VERBOSE) { "Starting BLEScanner mode=$mode, compat=$compat, unfiltered=$unfiltered" }
} else { val filters = if (unfiltered) {
ProximityPairing.getBleScanFilter() setOf(getUnfilteredFilter())
} } else {
bleScanner.scan( ProximityPairing.getBleScanFilter()
filters = filters,
scannerMode = mode
).map { it.preFilterAndMap(mode) }
} }
bleScanner.scan(
filters = filters,
scannerMode = mode,
compatMode = compat,
).map { it.preFilterAndMap(mode) }
}
} else { } else {
log(TAG, WARN) { "Bluetooth is currently disabled" } log(TAG, WARN) { "Bluetooth is currently disabled" }
flowOf(null) flowOf(null)
@@ -160,23 +163,7 @@ class PodMonitor @Inject constructor(
private fun getUnfilteredFilter(): ScanFilter { private fun getUnfilteredFilter(): ScanFilter {
val manufacturerData = ByteArray(2).apply { return ScanFilter.Builder().build()
this[0] = 0
this[1] = 0
}
val manufacturerDataMask = ByteArray(2).apply {
this[0] = 0
this[1] = 0
}
val builder = ScanFilter.Builder().apply {
setManufacturerData(
ContinuityProtocol.APPLE_COMPANY_IDENTIFIER,
manufacturerData,
manufacturerDataMask
)
}
return builder.build()
} }
companion object { companion object {
@@ -6,8 +6,7 @@ import android.widget.RemoteViews
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.pods.core.* import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods import eu.darken.capod.pods.core.apple.SingleApplePods
import javax.inject.Inject import javax.inject.Inject
@@ -17,13 +16,12 @@ class MonitorNotificationViewFactory @Inject constructor(
) { ) {
fun createContentView(device: PodDevice): RemoteViews = when (device) { fun createContentView(device: PodDevice): RemoteViews = when (device) {
is DualApplePods -> createDualApplePods(device) is DualAirPods -> createDualApplePods(device)
is SingleApplePods -> createSingleApplePods(device) is SingleApplePods -> createSingleApplePods(device)
is BasicSingleApplePods -> createSingleBasicApplePods(device)
else -> createUnknownDevice(device) else -> createUnknownDevice(device)
} }
private fun createDualApplePods(device: DualApplePods): RemoteViews = RemoteViews( private fun createDualApplePods(device: DualAirPods): RemoteViews = RemoteViews(
context.packageName, context.packageName,
R.layout.monitor_notification_dual_pods_small R.layout.monitor_notification_dual_pods_small
).apply { ).apply {
@@ -52,19 +50,12 @@ class MonitorNotificationViewFactory @Inject constructor(
setTextViewText(R.id.headphones_label, getLabel(context)) setTextViewText(R.id.headphones_label, getLabel(context))
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent)) setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent))
setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context)) setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context))
setViewVisibility(R.id.headphones_charging, if (isHeadsetBeingCharged) View.VISIBLE else View.GONE) if (this is HasEarDetection) {
setViewVisibility(R.id.headphones_worn, if (isHeadphonesBeingWorn) View.VISIBLE else View.GONE) setViewVisibility(R.id.headphones_worn, if (isBeingWorn) View.VISIBLE else View.GONE)
} }
} if (this is HasChargeDetectionDual) {
setViewVisibility(R.id.headphones_charging, if (isHeadsetBeingCharged) View.VISIBLE else View.GONE)
private fun createSingleBasicApplePods(device: BasicSingleApplePods): RemoteViews = RemoteViews( }
context.packageName,
R.layout.monitor_notification_single_pods_basic_small
).apply {
device.apply {
setTextViewText(R.id.headphones_label, getLabel(context))
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent))
setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context))
} }
} }
@@ -1,6 +1,6 @@
package eu.darken.capod.pods.core package eu.darken.capod.pods.core
interface HasDualPods { interface DualPodDevice : PodDevice {
enum class Pod { enum class Pod {
LEFT, LEFT,
@@ -0,0 +1,7 @@
package eu.darken.capod.pods.core
interface HasChargeDetection {
val isHeadsetBeingCharged: Boolean
}
@@ -0,0 +1,14 @@
package eu.darken.capod.pods.core
interface HasChargeDetectionDual : HasChargeDetection {
val isLeftPodCharging: Boolean
val isRightPodCharging: Boolean
val isEitherPodCharging: Boolean
get() = isLeftPodCharging || isRightPodCharging
override val isHeadsetBeingCharged: Boolean
get() = isEitherPodCharging
}
@@ -0,0 +1,9 @@
package eu.darken.capod.pods.core
interface HasDualMicrophone {
val isLeftPodMicrophone: Boolean
val isRightPodMicrophone: Boolean
}
@@ -1,6 +1,6 @@
package eu.darken.capod.pods.core package eu.darken.capod.pods.core
interface HasEarDetection : PodDevice { interface HasEarDetection {
val isBeingWorn: Boolean val isBeingWorn: Boolean
@@ -1,6 +1,6 @@
package eu.darken.capod.pods.core package eu.darken.capod.pods.core
interface HasDualEarDetection : HasEarDetection { interface HasEarDetectionDual : HasEarDetection {
val isLeftPodInEar: Boolean val isLeftPodInEar: Boolean
@@ -0,0 +1,19 @@
package eu.darken.capod.pods.core
import android.content.Context
import androidx.annotation.ColorRes
interface HasPodStyle {
val podStyle: PodStyle
interface PodStyle {
fun getLabel(context: Context): String
@ColorRes
fun getColor(context: Context): Int
val identifier: String
}
}
@@ -0,0 +1,12 @@
package eu.darken.capod.pods.core
import android.content.Context
interface HasStateDetection {
val state: State
interface State {
fun getLabel(context: Context): String
}
}
@@ -100,6 +100,12 @@ interface PodDevice {
@Json(name = "beats.powerbeats.pro") POWERBEATS_PRO( @Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro" "Power Beats Pro"
), ),
@Json(name = "fakes.tws.i99999") TWS_I99999(
"TWS i99999"
),
@Json(name = "fakes.varunr.airpodspro") VARUNR_AIRPODS_PRO(
"Fake AirPods Pro"
),
@Json(name = "unknown") UNKNOWN( @Json(name = "unknown") UNKNOWN(
"Unknown" "Unknown"
); );
@@ -8,11 +8,11 @@ import java.time.Duration
import java.time.Instant import java.time.Instant
import kotlin.math.roundToInt import kotlin.math.roundToInt
fun HasDualPods.getBatteryLevelLeftPod(context: Context): String = fun DualPodDevice.getBatteryLevelLeftPod(context: Context): String =
batteryLeftPodPercent?.let { "${(it * 100).roundToInt()}%" } batteryLeftPodPercent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label) ?: context.getString(R.string.general_value_not_available_label)
fun HasDualPods.getBatteryLevelRightPod(context: Context): String = fun DualPodDevice.getBatteryLevelRightPod(context: Context): String =
batteryRightPodPercent?.let { "${(it * 100).roundToInt()}%" } batteryRightPodPercent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label) ?: context.getString(R.string.general_value_not_available_label)
@@ -20,7 +20,7 @@ fun HasCase.getBatteryLevelCase(context: Context): String =
batteryCasePercent?.let { "${(it * 100).roundToInt()}%" } batteryCasePercent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label) ?: context.getString(R.string.general_value_not_available_label)
fun HasSinglePod.getBatteryLevelHeadset(context: Context): String = fun SinglePodDevice.getBatteryLevelHeadset(context: Context): String =
batteryHeadsetPercent?.let { "${(it * 100).roundToInt()}%" } batteryHeadsetPercent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label) ?: context.getString(R.string.general_value_not_available_label)
@@ -1,6 +1,6 @@
package eu.darken.capod.pods.core package eu.darken.capod.pods.core
interface HasSinglePod { interface SinglePodDevice : PodDevice {
val batteryHeadsetPercent: Float? val batteryHeadsetPercent: Float?
} }
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice 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.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -54,25 +54,13 @@ class AppleFactory @Inject constructor(
val factory = podFactories.firstOrNull { it.isResponsible(pm) } val factory = podFactories.firstOrNull { it.isResponsible(pm) }
val device = (factory ?: unknownAppleFactory).create( return@withLock (factory ?: unknownAppleFactory).create(
scanResult = scanResult, scanResult = scanResult,
proximityMessage = pm, message = pm,
) )
if (factory == null && !SILENCED_PMS.contains(device.rawDeviceModel) && scanResult.address != "6E:9E:D1:49:D2:6D") {
SILENCED_PMS.add(device.rawDeviceModel)
Bugs.report(
tag = TAG,
message = "Unknown proximity message type",
exception = IllegalArgumentException("Unknown ProximityMessage: $pm")
)
}
return@withLock device
} }
companion object { companion object {
private val SILENCED_PMS = mutableSetOf<UShort>()
private val TAG = logTag("Pod", "Apple", "Factory") private val TAG = logTag("Pod", "Apple", "Factory")
} }
} }
@@ -7,6 +7,8 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet import dagger.multibindings.IntoSet
import eu.darken.capod.pods.core.apple.airpods.* import eu.darken.capod.pods.core.apple.airpods.*
import eu.darken.capod.pods.core.apple.beats.* import eu.darken.capod.pods.core.apple.beats.*
import eu.darken.capod.pods.core.apple.misc.Twsi99999
import eu.darken.capod.pods.core.apple.misc.VarunrAirPodsPro
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@Module @Module
@@ -25,4 +27,8 @@ abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.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 powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods> @Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakesTwsi999999(factory: Twsi99999.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet
abstract fun fakesVarunrAirPodsPro(factory: VarunrAirPodsPro.Factory): ApplePodsFactory<out ApplePods>
} }
@@ -5,7 +5,9 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble 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.PodDevice
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@@ -21,9 +23,9 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
val deviceColor: UByte, val deviceColor: UByte,
) )
fun ProximityPairing.Message.getApplePodsMarkings(): Markings = Markings( private fun ProximityPairing.Message.getApplePodsMarkings(): Markings = Markings(
vendor = ProximityPairing.CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING, vendor = ProximityPairing.CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING,
length = ProximityPairing.PROXIMITY_PAIRING_MESSAGE_LENGTH.toUByte(), length = ProximityPairing.PAIRING_MESSAGE_LENGTH.toUByte(),
device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(), device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
// Make comparison order independent // Make comparison order independent
podBatteryData = setOf(data[4].upperNibble, data[4].lowerNibble), podBatteryData = setOf(data[4].upperNibble, data[4].lowerNibble),
@@ -70,6 +72,22 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
internal val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>() internal val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
fun KnownDevice.getLatestCaseBattery(): Float? = history
.filterIsInstance<HasCase>()
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
fun KnownDevice.getLatestCaseLidState(basic: DualAirPods): DualAirPods.LidState? {
val definitive = setOf(DualAirPods.LidState.OPEN, DualAirPods.LidState.CLOSED)
if (definitive.contains(basic.caseLidState)) return null
return history
.filterIsInstance<AirPodsPro>()
.lastOrNull { it.caseLidState != DualAirPods.LidState.NOT_IN_CASE }
?.caseLidState
}
internal open fun searchHistory(current: PodType): KnownDevice? { internal open fun searchHistory(current: PodType): KnownDevice? {
val scanResult = current.scanResult val scanResult = current.scanResult
val message = current.proximityMessage val message = current.proximityMessage
@@ -138,10 +156,10 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
dirty = data[1] dirty = data[1]
) )
abstract fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean abstract fun isResponsible(message: ProximityPairing.Message): Boolean
abstract fun create( abstract fun create(
scanResult: BleScanResult, scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message, message: ProximityPairing.Message,
): ApplePods ): ApplePods
} }
@@ -1,19 +0,0 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.pods.core.HasSinglePod
interface BasicSingleApplePods : ApplePods, HasSinglePod {
override val batteryHeadsetPercent: Float?
get() = when (val value = rawPodsBattery.lowerNibble.toInt()) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
@@ -1,3 +0,0 @@
package eu.darken.capod.pods.core.apple
abstract class BasicSingleApplePodsFactory(private val tag: String) : ApplePodsFactory<BasicSingleApplePods>(tag)
@@ -7,12 +7,11 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.isBitSet import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.HasCase import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.HasDualEarDetection import eu.darken.capod.pods.core.DualPodDevice.Pod
import eu.darken.capod.pods.core.HasDualPods
import eu.darken.capod.pods.core.HasDualPods.Pod
interface DualApplePods : ApplePods, HasDualPods, HasDualEarDetection, HasCase { interface DualAirPods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
HasStateDetection, HasDualMicrophone, HasAppleColor {
val primaryPod: Pod val primaryPod: Pod
get() = when (rawStatus.isBitSet(5)) { get() = when (rawStatus.isBitSet(5)) {
@@ -86,23 +85,23 @@ interface DualApplePods : ApplePods, HasDualPods, HasDualEarDetection, HasCase {
* The data flip bit is set if the left pod is primary. * The data flip bit is set if the left pod is primary.
* For the pod that is in the case, this is flipped again though. * For the pod that is in the case, this is flipped again though.
*/ */
val isLeftPodMicrophone: Boolean override val isLeftPodMicrophone: Boolean
get() = rawStatus.isBitSet(5) xor isThisPodInThecase get() = rawStatus.isBitSet(5) xor isThisPodInThecase
/** /**
* The data flip bit is UNset if the right pod is primary. * 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. * For the pod that is in the case, this is flipped again though.
*/ */
val isRightPodMicrophone: Boolean override val isRightPodMicrophone: Boolean
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
val isLeftPodCharging: Boolean override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) { get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0) false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1) true -> rawFlags.isBitSet(1)
} }
val isRightPodCharging: Boolean override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) { get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1) false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0) true -> rawFlags.isBitSet(0)
@@ -143,35 +142,10 @@ interface DualApplePods : ApplePods, HasDualPods, HasDualEarDetection, HasCase {
UNKNOWN(0xFF..0xFF); UNKNOWN(0xFF..0xFF);
} }
val deviceColor: DeviceColor override val state: ConnectionState
get() = DeviceColor.values().firstOrNull { it.raw == rawDeviceColor } ?: DeviceColor.UNKNOWN
enum class DeviceColor(val raw: UByte?) {
WHITE(0x00),
BLACK(0x01),
RED(0x02),
BLUE(0x03),
PINK(0x04),
GRAY(0x05),
SILVER(0x06),
GOLD(0x07),
ROSE_GOLD(0x08),
SPACE_GRAY(0x09),
DARK_BLUE(0x0a),
LIGHT_BLUE(0x0b),
YELLOW(0x0c),
UNKNOWN(null);
constructor(raw: Int) : this(raw.toUByte())
}
val connectionState: ConnectionState
get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN
fun getConnectionStateLabel(context: Context): String = context.getString(connectionState.labelRes) enum class ConnectionState(val raw: UByte?, @StringRes val labelRes: Int) : HasStateDetection.State {
enum class ConnectionState(val raw: UByte?, @StringRes val labelRes: Int) {
DISCONNECTED(0x00, R.string.pods_connection_state_disconnected_label), DISCONNECTED(0x00, R.string.pods_connection_state_disconnected_label),
IDLE(0x04, R.string.pods_connection_state_idle_label), IDLE(0x04, R.string.pods_connection_state_idle_label),
MUSIC(0x05, R.string.pods_connection_state_music_label), MUSIC(0x05, R.string.pods_connection_state_music_label),
@@ -180,6 +154,8 @@ interface DualApplePods : ApplePods, HasDualPods, HasDualEarDetection, HasCase {
HANGING_UP(0x09, R.string.pods_connection_state_hanging_up_label), HANGING_UP(0x09, R.string.pods_connection_state_hanging_up_label),
UNKNOWN(null, R.string.pods_connection_state_unknown_label); UNKNOWN(null, R.string.pods_connection_state_unknown_label);
override fun getLabel(context: Context): String = context.getString(labelRes)
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes) constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
} }
@@ -3,18 +3,17 @@ package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualApplePods>(tag) { abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualAirPods>(tag) {
fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings( fun DualAirPods.getCaseMatchMarkings() = SplitPodsMarkings(
leftPodBattery = batteryLeftPodPercent, leftPodBattery = batteryLeftPodPercent,
rightPodBattery = batteryRightPodPercent, rightPodBattery = batteryRightPodPercent,
microPhoneLeft = isLeftPodMicrophone, microPhoneLeft = isLeftPodMicrophone,
microPhoneRight = isRightPodMicrophone, microPhoneRight = isRightPodMicrophone,
chargingLeft = isLeftPodCharging, chargingLeft = isLeftPodCharging,
chargingRight = isRightPodCharging, chargingRight = isRightPodCharging,
color = deviceColor, color = rawDeviceColor,
model = model model = model
) )
@@ -28,36 +27,21 @@ abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<
val microPhoneRight: Boolean, val microPhoneRight: Boolean,
val chargingLeft: Boolean, val chargingLeft: Boolean,
val chargingRight: Boolean, val chargingRight: Boolean,
val color: DualApplePods.DeviceColor, val color: UByte,
val model: PodDevice.Model, val model: PodDevice.Model,
) )
private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualApplePods): Collection<KnownDevice> { private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualAirPods): Collection<KnownDevice> {
val target = device.getCaseMatchMarkings() val target = device.getCaseMatchMarkings()
return filter { known -> return filter { known ->
known.history known.history
.filterIsInstance<DualApplePods>() .filterIsInstance<DualAirPods>()
.any { it.getCaseMatchMarkings() == target } .any { it.getCaseMatchMarkings() == target }
} }
} }
fun KnownDevice.getLatestCaseBattery(): Float? = history override fun searchHistory(current: DualAirPods): KnownDevice? {
.filterIsInstance<DualApplePods>()
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
val definitive = setOf(DualApplePods.LidState.OPEN, DualApplePods.LidState.CLOSED)
if (definitive.contains(basic.caseLidState)) return null
return history
.filterIsInstance<AirPodsPro>()
.lastOrNull { it.caseLidState != DualApplePods.LidState.NOT_IN_CASE }
?.caseLidState
}
override fun searchHistory(current: DualApplePods): KnownDevice? {
val basicResult = super.searchHistory(current) val basicResult = super.searchHistory(current)
val caseIgnored = knownDevices.values.findSplitPodsMatch(current) val caseIgnored = knownDevices.values.findSplitPodsMatch(current)
@@ -0,0 +1,38 @@
package eu.darken.capod.pods.core.apple
import android.content.Context
import eu.darken.capod.pods.core.HasPodStyle
interface HasAppleColor : ApplePods, HasPodStyle {
override val podStyle: HasPodStyle.PodStyle
get() = DeviceColor.values()
.firstOrNull { it.raw == rawDeviceColor }
?: DeviceColor.UNKNOWN
enum class DeviceColor(val raw: UByte?) : HasPodStyle.PodStyle {
WHITE(0x00),
BLACK(0x01),
RED(0x02),
BLUE(0x03),
PINK(0x04),
GRAY(0x05),
SILVER(0x06),
GOLD(0x07),
ROSE_GOLD(0x08),
SPACE_GRAY(0x09),
DARK_BLUE(0x0a),
LIGHT_BLUE(0x0b),
YELLOW(0x0c),
UNKNOWN(null);
override fun getLabel(context: Context): String = this.name
override fun getColor(context: Context): Int = android.R.color.white
override val identifier: String
get() = name
constructor(raw: Int) : this(raw.toUByte())
}
}
@@ -1,17 +1,22 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.isBitSet import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.HasEarDetection import eu.darken.capod.common.lowerNibble
import eu.darken.capod.pods.core.HasSinglePod import eu.darken.capod.pods.core.SinglePodDevice
interface SingleApplePods : BasicSingleApplePods, HasEarDetection, HasSinglePod { /**
* Devices that only present a single charge level, e.g. most Beats devices
*/
interface SingleApplePods : ApplePods, SinglePodDevice, HasAppleColor {
val isHeadphonesBeingWorn: Boolean override val batteryHeadsetPercent: Float?
get() = rawStatus.isBitSet(1) get() = when (val value = rawPodsBattery.lowerNibble.toInt()) {
15 -> null
val isHeadsetBeingCharged: Boolean else -> if (value > 10) {
get() = rawFlags.isBitSet(0) log { "Headset above 100% battery: $value" }
1.0f
override val isBeingWorn: Boolean } else {
get() = isHeadphonesBeingWorn (value / 10f)
}
}
} }
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
@@ -20,15 +20,15 @@ data class AirPodsGen1 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null private val cachedCaseState: DualAirPods.LidState? = null
) : DualApplePods { ) : DualAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1 override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualApplePods.LidState override val caseLidState: DualAirPods.LidState
get() = cachedCaseState ?: super.caseLidState get() = cachedCaseState ?: super.caseLidState
override val rssi: Int override val rssi: Int
@@ -36,11 +36,13 @@ data class AirPodsGen1 constructor(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) { class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
proximityMessage.getModelInfo().full == DEVICE_CODE getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
var basic = AirPodsGen1(scanResult = scanResult, proximityMessage = proximityMessage) override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsGen1(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
@@ -20,15 +20,15 @@ data class AirPodsGen2 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null private val cachedCaseState: DualAirPods.LidState? = null
) : DualApplePods { ) : DualAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2 override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualApplePods.LidState override val caseLidState: DualAirPods.LidState
get() = cachedCaseState ?: super.caseLidState get() = cachedCaseState ?: super.caseLidState
override val rssi: Int override val rssi: Int
@@ -36,11 +36,12 @@ data class AirPodsGen2 constructor(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) { class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
proximityMessage.getModelInfo().full == DEVICE_CODE getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsGen2(scanResult = scanResult, proximityMessage = proximityMessage) var basic = AirPodsGen2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
@@ -20,14 +20,14 @@ data class AirPodsGen3 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null private val cachedCaseState: DualAirPods.LidState? = null
) : DualApplePods { ) : DualAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3 override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualApplePods.LidState override val caseLidState: DualAirPods.LidState
get() = cachedCaseState ?: super.caseLidState get() = cachedCaseState ?: super.caseLidState
override val rssi: Int override val rssi: Int
@@ -35,11 +35,12 @@ data class AirPodsGen3 constructor(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) { class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
proximityMessage.getModelInfo().full == DEVICE_CODE getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsGen3(scanResult = scanResult, proximityMessage = proximityMessage) var basic = AirPodsGen3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -2,6 +2,8 @@ package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods import eu.darken.capod.pods.core.apple.SingleApplePods
@@ -19,20 +21,30 @@ data class AirPodsMax(
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
) : SingleApplePods { ) : SingleApplePods, HasEarDetection {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
override val rssi: Int override val rssi: Int
get() = rssiAverage ?: super.rssi get() = rssiAverage ?: super.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) { class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsMax(scanResult = scanResult, proximityMessage = proximityMessage) var basic = AirPodsMax(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -48,7 +60,6 @@ data class AirPodsMax(
rssiAverage = result.averageRssi(basic.rssi), rssiAverage = result.averageRssi(basic.rssi),
) )
} }
} }
companion object { companion object {
@@ -4,8 +4,8 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState import eu.darken.capod.pods.core.apple.DualAirPods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
@@ -22,7 +22,7 @@ data class AirPodsPro(
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null private val cachedCaseState: LidState? = null
) : DualApplePods { ) : DualAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
@@ -37,11 +37,12 @@ data class AirPodsPro(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) { class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
proximityMessage.getModelInfo().full == DEVICE_CODE getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsPro(scanResult = scanResult, proximityMessage = proximityMessage) var basic = AirPodsPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -4,8 +4,8 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -19,20 +19,21 @@ data class BeatsFlex(
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : SingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_FLEX override val model: PodDevice.Model = PodDevice.Model.BEATS_FLEX
override val rssi: Int override val rssi: Int
get() = rssiAverage ?: super.rssi get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) { class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
proximityMessage.getModelInfo().full == DEVICE_CODE getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = BeatsFlex(scanResult = scanResult, proximityMessage = proximityMessage) var basic = BeatsFlex(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -4,8 +4,8 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -19,19 +19,20 @@ data class BeatsSolo3(
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : SingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_SOLO_3 override val model: PodDevice.Model = PodDevice.Model.BEATS_SOLO_3
override val rssi: Int override val rssi: Int
get() = rssiAverage ?: super.rssi get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) { class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
proximityMessage.getModelInfo().full == DEVICE_CODE getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = BeatsSolo3(scanResult = scanResult, proximityMessage = proximityMessage) var basic = BeatsSolo3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -4,8 +4,8 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -19,17 +19,18 @@ data class BeatsStudio3(
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : SingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3 override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) { class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = BeatsStudio3(scanResult = scanResult, proximityMessage = proximityMessage) var basic = BeatsStudio3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -45,7 +46,6 @@ data class BeatsStudio3(
rssiAverage = result.averageRssi(basic.rssi), rssiAverage = result.averageRssi(basic.rssi),
) )
} }
} }
companion object { companion object {
@@ -4,8 +4,8 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -19,20 +19,21 @@ data class BeatsX(
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : SingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_X override val model: PodDevice.Model = PodDevice.Model.BEATS_X
override val rssi: Int override val rssi: Int
get() = rssiAverage ?: super.rssi get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) { class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
proximityMessage.getModelInfo().full == DEVICE_CODE getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = BeatsX(scanResult = scanResult, proximityMessage = proximityMessage) var basic = BeatsX(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -4,8 +4,8 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -19,19 +19,21 @@ data class PowerBeats3(
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : SingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_3 override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_3
override val rssi: Int override val rssi: Int
get() = rssiAverage ?: super.rssi get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) { class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
proximityMessage.getModelInfo().full == DEVICE_CODE getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = PowerBeats3(scanResult = scanResult, proximityMessage = proximityMessage) var basic = PowerBeats3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
@@ -20,15 +20,15 @@ data class PowerBeatsPro(
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null private val cachedCaseState: DualAirPods.LidState? = null
) : DualApplePods { ) : DualAirPods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualApplePods.LidState override val caseLidState: DualAirPods.LidState
get() = cachedCaseState ?: super.caseLidState get() = cachedCaseState ?: super.caseLidState
override val rssi: Int override val rssi: Int
@@ -36,11 +36,12 @@ data class PowerBeatsPro(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) { class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = PowerBeatsPro(scanResult = scanResult, proximityMessage = proximityMessage) var basic = PowerBeatsPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -0,0 +1,143 @@
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.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasDualMicrophone
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 java.time.Instant
import javax.inject.Inject
/**
* Basically an AirPods GEN1 clone
* Similar data structure but a lot of placeholder values or hardcoded values
*/
data class Twsi99999 constructor(
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 confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasDualMicrophone, HasCase {
override val model: PodDevice.Model = PodDevice.Model.TWS_I99999
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
}
}
}
val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
/**
* The data flip bit is set if the left pod is primary.
* For the pod that is in the case, this is flipped again though.
*/
override val isLeftPodMicrophone: Boolean
get() = rawStatus.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
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
}
}
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor() : ApplePodsFactory<Twsi99999>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): 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 = Twsi99999(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
)
}
}
companion object {
private val DEVICE_CODE = 0x0220.toUShort()
private val TAG = logTag("PodDevice", "Apple", "TWS", "i99999")
}
}
@@ -1,10 +1,12 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple.misc
import android.content.Context import android.content.Context
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice 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.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -28,13 +30,13 @@ data class UnknownAppleDevice(
get() = rssiAverage ?: super.rssi get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : ApplePodsFactory<ApplePods>(TAG) { class Factory @Inject constructor() : ApplePodsFactory<ApplePods>(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = true override fun isResponsible(message: ProximityPairing.Message): Boolean = true
override fun create( override fun create(
scanResult: BleScanResult, scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message, message: ProximityPairing.Message,
): ApplePods { ): ApplePods {
var basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = proximityMessage) var basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -0,0 +1,143 @@
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.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasDualMicrophone
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 java.time.Instant
import javax.inject.Inject
/**
* AirPods Pro clone similar to Twsi999999.
* Shorter data structure.
*/
data class VarunrAirPodsPro constructor(
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 confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasDualMicrophone, HasCase {
override val model: PodDevice.Model = PodDevice.Model.VARUNR_AIRPODS_PRO
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
}
}
}
val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
/**
* The data flip bit is set if the left pod is primary.
* For the pod that is in the case, this is flipped again though.
*/
override val isLeftPodMicrophone: Boolean
get() = rawStatus.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
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
}
}
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor() : ApplePodsFactory<VarunrAirPodsPro>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): 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 = VarunrAirPodsPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
)
}
}
companion object {
private val DEVICE_CODE = 0x0E20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "Varunr", "AirPodsPro")
}
}
@@ -27,10 +27,6 @@ object ProximityPairing {
log { "Not a proximity pairing message: $this" } log { "Not a proximity pairing message: $this" }
return null return null
} }
if (message.length != PROXIMITY_PAIRING_MESSAGE_LENGTH) {
log { "Proximity pairing message has invalid length." }
return null
}
return Message( return Message(
type = message.type, type = message.type,
@@ -43,7 +39,7 @@ object ProximityPairing {
fun getBleScanFilter(): Set<ScanFilter> { fun getBleScanFilter(): Set<ScanFilter> {
val manufacturerData = ByteArray(CONTINUITY_PROTOCOL_MESSAGE_LENGTH).apply { val manufacturerData = ByteArray(CONTINUITY_PROTOCOL_MESSAGE_LENGTH).apply {
this[0] = CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING.toByte() this[0] = CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING.toByte()
this[1] = PROXIMITY_PAIRING_MESSAGE_LENGTH.toByte() this[1] = PAIRING_MESSAGE_LENGTH.toByte()
} }
val manufacturerDataMask = ByteArray(CONTINUITY_PROTOCOL_MESSAGE_LENGTH).apply { val manufacturerDataMask = ByteArray(CONTINUITY_PROTOCOL_MESSAGE_LENGTH).apply {
@@ -62,5 +58,7 @@ object ProximityPairing {
private const val CONTINUITY_PROTOCOL_MESSAGE_LENGTH = 27 private const val CONTINUITY_PROTOCOL_MESSAGE_LENGTH = 27
internal val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte() internal val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte()
internal const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25
// This is the default message length among official Apple devices, clones may have different length
internal const val PAIRING_MESSAGE_LENGTH = 25
} }
@@ -8,9 +8,9 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.HasDualEarDetection
import eu.darken.capod.pods.core.HasEarDetection import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.reaction.settings.ReactionSettings import eu.darken.capod.reaction.settings.ReactionSettings
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import javax.inject.Inject import javax.inject.Inject
@@ -68,12 +68,12 @@ class AutoConnect @Inject constructor(
val conditionFulfilled = when (condition) { val conditionFulfilled = when (condition) {
AutoConnectCondition.WHEN_SEEN -> true AutoConnectCondition.WHEN_SEEN -> true
AutoConnectCondition.CASE_OPEN -> when (mainDevice) { AutoConnectCondition.CASE_OPEN -> when (mainDevice) {
is DualApplePods -> mainDevice.caseLidState == DualApplePods.LidState.OPEN is DualAirPods -> mainDevice.caseLidState == DualAirPods.LidState.OPEN
else -> true else -> true
} }
AutoConnectCondition.IN_EAR -> when (mainDevice) { AutoConnectCondition.IN_EAR -> when (mainDevice) {
is HasEarDetection -> { is HasEarDetection -> {
if (mainDevice is HasDualEarDetection && reactionSettings.onePodMode.value) { if (mainDevice is HasEarDetectionDual && reactionSettings.onePodMode.value) {
mainDevice.isEitherPodInEar mainDevice.isEitherPodInEar
} else { } else {
mainDevice.isBeingWorn mainDevice.isBeingWorn
@@ -9,8 +9,8 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.withPrevious import eu.darken.capod.common.flow.withPrevious
import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.HasDualEarDetection
import eu.darken.capod.pods.core.HasEarDetection import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.reaction.settings.ReactionSettings import eu.darken.capod.reaction.settings.ReactionSettings
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import javax.inject.Inject import javax.inject.Inject
@@ -48,7 +48,7 @@ class PlayPause @Inject constructor(
return@onEach return@onEach
} }
if (previous is HasDualEarDetection && current is HasDualEarDetection && reactionSettings.onePodMode.value) { if (previous is HasEarDetectionDual && current is HasEarDetectionDual && reactionSettings.onePodMode.value) {
log(TAG) { "Ear status changed for dual pod device in single pod mode." } log(TAG) { "Ear status changed for dual pod device in single pod mode." }
val previousWorn = previous.isEitherPodInEar val previousWorn = previous.isEitherPodInEar
val currentWorn = current.isEitherPodInEar val currentWorn = current.isEitherPodInEar
@@ -8,7 +8,7 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.withPrevious import eu.darken.capod.common.flow.withPrevious
import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.reaction.popup.ui.PopUpWindow import eu.darken.capod.reaction.popup.ui.PopUpWindow
import eu.darken.capod.reaction.settings.ReactionSettings import eu.darken.capod.reaction.settings.ReactionSettings
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -39,7 +39,7 @@ class PopUpReaction @Inject constructor(
.withPrevious() .withPrevious()
.setupCommonEventHandlers(TAG) { "monitor" } .setupCommonEventHandlers(TAG) { "monitor" }
.map { (previous, current) -> .map { (previous, current) ->
if (previous is DualApplePods? && current is DualApplePods) { if (previous is DualAirPods? && current is DualAirPods) {
log(TAG, VERBOSE) { log(TAG, VERBOSE) {
val prev = previous?.rawCaseLidState?.let { String.format("%02X", it.toByte()) } val prev = previous?.rawCaseLidState?.let { String.format("%02X", it.toByte()) }
val cur = current.rawCaseLidState.let { String.format("%02X", it.toByte()) } val cur = current.rawCaseLidState.let { String.format("%02X", it.toByte()) }
@@ -60,8 +60,8 @@ class PopUpReaction @Inject constructor(
} }
} }
private suspend fun tryPopWindow(current: DualApplePods) { private suspend fun tryPopWindow(current: DualAirPods) {
if (current.caseLidState == DualApplePods.LidState.OPEN) { if (current.caseLidState == DualAirPods.LidState.OPEN) {
log(TAG, INFO) { "Show popup" } log(TAG, INFO) { "Show popup" }
val now = Instant.now() val now = Instant.now()
@@ -78,9 +78,9 @@ class PopUpReaction @Inject constructor(
withContext(dispatcherProvider.Main) { withContext(dispatcherProvider.Main) {
popupWindow.show(current) popupWindow.show(current)
} }
} else if (current.caseLidState != DualApplePods.LidState.OPEN) { } else if (current.caseLidState != DualAirPods.LidState.OPEN) {
when (current.caseLidState) { when (current.caseLidState) {
DualApplePods.LidState.CLOSED -> { DualAirPods.LidState.CLOSED -> {
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." } log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
coolDowns.remove(current.identifier) coolDowns.remove(current.identifier)
} }
@@ -10,11 +10,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.debug.autoreport.DebugSettings import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.databinding.PopupNotificationDualPodsBinding import eu.darken.capod.databinding.PopupNotificationDualPodsBinding
import eu.darken.capod.databinding.PopupNotificationSinglePodsBasicBinding
import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding
import eu.darken.capod.pods.core.* import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods import eu.darken.capod.pods.core.apple.SingleApplePods
import javax.inject.Inject import javax.inject.Inject
@@ -28,16 +26,13 @@ class PopUpPodViewFactory @Inject constructor(
private val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater private val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
fun createContentView(parent: ViewGroup, device: PodDevice): View = when (device) { fun createContentView(parent: ViewGroup, device: PodDevice): View = when (device) {
is DualApplePods -> createDualApplePods(parent, device) is DualAirPods -> createDualApplePods(parent, device)
is SingleApplePods -> createSingleApplePods(parent, device) // Unused, has no case to trigger reaction? // Unused, has no case to trigger reaction?
is BasicSingleApplePods -> createSingleBasicApplePods( is SingleApplePods -> createSingleApplePods(parent, device)
parent,
device
) // Unused, has no case to trigger reaction?
else -> throw IllegalArgumentException("Unexpected device: $device") else -> throw IllegalArgumentException("Unexpected device: $device")
} }
private fun createDualApplePods(parent: ViewGroup, device: DualApplePods): View = private fun createDualApplePods(parent: ViewGroup, device: DualAirPods): View =
PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply { PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply {
device.apply { device.apply {
podIcon.setImageResource(iconRes) podIcon.setImageResource(iconRes)
@@ -72,17 +67,4 @@ class PopUpPodViewFactory @Inject constructor(
} }
}.root }.root
private fun createSingleBasicApplePods(parent: ViewGroup, device: BasicSingleApplePods): View =
PopupNotificationSinglePodsBasicBinding.inflate(layoutInflater, parent, false).apply {
device.apply {
headphonesIcon.setImageResource(iconRes)
headphonesLabel.text = getLabel(context)
signal.text = getSignalQuality(context)
signal.isInvisible = debugSettings.isDebugModeEnabled.value
headphonesBatteryIcon.setImageResource(getBatteryDrawable(batteryHeadsetPercent))
headphonesBatteryLabel.text = getBatteryLevelHeadset(context)
}
}.root
} }
@@ -12,13 +12,11 @@ import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.uix.PreferenceFragment2 import eu.darken.capod.common.uix.PreferenceFragment2
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.ui.settings.general.DeviceSelectionDialogFactory import eu.darken.capod.main.ui.settings.general.DeviceSelectionDialogFactory
import eu.darken.capod.reaction.autoconnect.AutoConnectCondition import eu.darken.capod.reaction.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.settings.ReactionSettings import eu.darken.capod.reaction.settings.ReactionSettings
import kotlinx.coroutines.runBlocking
import javax.inject.Inject import javax.inject.Inject
@Keep @Keep
@@ -36,6 +34,7 @@ class ReactionSettingsFragment : PreferenceFragment2() {
override val preferenceFile: Int = R.xml.preferences_reactions override val preferenceFile: Int = R.xml.preferences_reactions
private var isPro: Boolean = false
private val autoConnectConditionPref by lazy { findPreference<ListPreference>(settings.autoConnectCondition.key)!! } private val autoConnectConditionPref by lazy { findPreference<ListPreference>(settings.autoConnectCondition.key)!! }
override fun onPreferencesCreated() { override fun onPreferencesCreated() {
@@ -44,12 +43,11 @@ class ReactionSettingsFragment : PreferenceFragment2() {
entryValues = AutoConnectCondition.values().map { settings.autoConnectCondition.rawWriter(it) as String } entryValues = AutoConnectCondition.values().map { settings.autoConnectCondition.rawWriter(it) as String }
.toTypedArray() .toTypedArray()
} }
super.onPreferencesCreated() super.onPreferencesCreated()
} }
override fun onPreferenceTreeClick(preference: Preference): Boolean { override fun onPreferenceTreeClick(preference: Preference): Boolean {
val isPro = runBlocking { upgradeRepo.isPro() }
if (preference.key == reactionSettings.autoPlay.key && !isPro) { if (preference.key == reactionSettings.autoPlay.key && !isPro) {
preference as CheckBoxPreference preference as CheckBoxPreference
upgradeRepo.launchBillingFlow(requireActivity()) upgradeRepo.launchBillingFlow(requireActivity())
@@ -95,6 +93,8 @@ class ReactionSettingsFragment : PreferenceFragment2() {
autoConnectConditionPref.isEnabled = it autoConnectConditionPref.isEnabled = it
} }
vm.isPro.observe2 { isPro = true }
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
} }
@@ -6,6 +6,8 @@ import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel3 import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.common.upgrade.UpgradeRepo
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -13,10 +15,13 @@ class ReactionSettingsFragmentVM @Inject constructor(
private val handle: SavedStateHandle, private val handle: SavedStateHandle,
private val dispatcherProvider: DispatcherProvider, private val dispatcherProvider: DispatcherProvider,
private val bluetoothManager: BluetoothManager2, private val bluetoothManager: BluetoothManager2,
private val upgradeRepo: UpgradeRepo,
) : ViewModel3(dispatcherProvider) { ) : ViewModel3(dispatcherProvider) {
val bondedDevices = bluetoothManager.bondedDevices().toList() val bondedDevices = bluetoothManager.bondedDevices().toList()
val isPro = upgradeRepo.upgradeInfo.map { it.isPro }.asLiveData2()
companion object { companion object {
private val TAG = logTag("Settings", "Reaction", "VM") private val TAG = logTag("Settings", "Reaction", "VM")
} }
@@ -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="M12,2A9,9 0 0,0 3,11V22L6,19L9,22L12,19L15,22L18,19L21,22V11A9,9 0 0,0 12,2M9,8A2,2 0 0,1 11,10A2,2 0 0,1 9,12A2,2 0 0,1 7,10A2,2 0 0,1 9,8M15,8A2,2 0 0,1 17,10A2,2 0 0,1 15,12A2,2 0 0,1 13,10A2,2 0 0,1 15,8Z" />
</vector>
@@ -6,5 +6,5 @@
android:tint="?attr/colorControlNormal"> android:tint="?attr/colorControlNormal">
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M20.38,8.53C20.54,8.13 21.06,6.54 20.21,4.39C20.21,4.39 18.9,4 15.91,6C14.66,5.67 13.33,5.62 12,5.62C10.68,5.62 9.34,5.67 8.09,6C5.1,3.97 3.79,4.39 3.79,4.39C2.94,6.54 3.46,8.13 3.63,8.53C2.61,9.62 2,11 2,12.72C2,19.16 6.16,20.61 12,20.61C17.79,20.61 22,19.16 22,12.72C22,11 21.39,9.62 20.38,8.53M12,19.38C7.88,19.38 4.53,19.19 4.53,15.19C4.53,14.24 5,13.34 5.8,12.61C7.14,11.38 9.43,12.03 12,12.03C14.59,12.03 16.85,11.38 18.2,12.61C19,13.34 19.5,14.23 19.5,15.19C19.5,19.18 16.13,19.38 12,19.38M8.86,13.12C8.04,13.12 7.36,14.12 7.36,15.34C7.36,16.57 8.04,17.58 8.86,17.58C9.69,17.58 10.36,16.58 10.36,15.34C10.36,14.11 9.69,13.12 8.86,13.12M15.14,13.12C14.31,13.12 13.64,14.11 13.64,15.34C13.64,16.58 14.31,17.58 15.14,17.58C15.96,17.58 16.64,16.58 16.64,15.34C16.64,14.11 16,13.12 15.14,13.12Z" /> android:pathData="M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.34,21.58 9.5,21.27 9.5,21C9.5,20.77 9.5,20.14 9.5,19.31C6.73,19.91 6.14,17.97 6.14,17.97C5.68,16.81 5.03,16.5 5.03,16.5C4.12,15.88 5.1,15.9 5.1,15.9C6.1,15.97 6.63,16.93 6.63,16.93C7.5,18.45 8.97,18 9.54,17.76C9.63,17.11 9.89,16.67 10.17,16.42C7.95,16.17 5.62,15.31 5.62,11.5C5.62,10.39 6,9.5 6.65,8.79C6.55,8.54 6.2,7.5 6.75,6.15C6.75,6.15 7.59,5.88 9.5,7.17C10.29,6.95 11.15,6.84 12,6.84C12.85,6.84 13.71,6.95 14.5,7.17C16.41,5.88 17.25,6.15 17.25,6.15C17.8,7.5 17.45,8.54 17.35,8.79C18,9.5 18.38,10.39 18.38,11.5C18.38,15.32 16.04,16.16 13.81,16.41C14.17,16.72 14.5,17.33 14.5,18.26C14.5,19.6 14.5,20.68 14.5,21C14.5,21.27 14.66,21.59 15.17,21.5C19.14,20.16 22,16.42 22,12A10,10 0 0,0 12,2Z" />
</vector> </vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24.0"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M20.38,8.53C20.54,8.13 21.06,6.54 20.21,4.39C20.21,4.39 18.9,4 15.91,6C14.66,5.67 13.33,5.62 12,5.62C10.68,5.62 9.34,5.67 8.09,6C5.1,3.97 3.79,4.39 3.79,4.39C2.94,6.54 3.46,8.13 3.63,8.53C2.61,9.62 2,11 2,12.72C2,19.16 6.16,20.61 12,20.61C17.79,20.61 22,19.16 22,12.72C22,11 21.39,9.62 20.38,8.53M12,19.38C7.88,19.38 4.53,19.19 4.53,15.19C4.53,14.24 5,13.34 5.8,12.61C7.14,11.38 9.43,12.03 12,12.03C14.59,12.03 16.85,11.38 18.2,12.61C19,13.34 19.5,14.23 19.5,15.19C19.5,19.18 16.13,19.38 12,19.38M8.86,13.12C8.04,13.12 7.36,14.12 7.36,15.34C7.36,16.57 8.04,17.58 8.86,17.58C9.69,17.58 10.36,16.58 10.36,15.34C10.36,14.11 9.69,13.12 8.86,13.12M15.14,13.12C14.31,13.12 13.64,14.11 13.64,15.34C13.64,16.58 14.31,17.58 15.14,17.58C15.96,17.58 16.64,16.58 16.64,15.34C16.64,14.11 16,13.12 15.14,13.12Z" />
</vector>
+1 -1
View File
@@ -22,7 +22,7 @@
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
tools:listitem="@layout/overview_pods_apple_dual_item" tools:listitem="@layout/overview_pods_dual_item"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" /> app:layout_constraintTop_toBottomOf="@id/toolbar" />
@@ -18,12 +18,12 @@
<ImageView <ImageView
android:id="@+id/pod_left_icon" android:id="@+id/pod_left_icon"
style="@style/PodInfoItemIcon" style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_airpod_left_24" /> android:src="@drawable/ic_airpod_left_24" />
<TextView <TextView
android:id="@+id/pod_left_label" android:id="@+id/pod_left_label"
style="@style/TextAppearance.Compat.Notification.Title" style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
@@ -31,12 +31,12 @@
<ImageView <ImageView
android:id="@+id/pod_left_charging" android:id="@+id/pod_left_charging"
style="@style/PodInfoItemIcon" style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24" /> android:src="@drawable/ic_baseline_power_24" />
<ImageView <ImageView
android:id="@+id/pod_left_ear" android:id="@+id/pod_left_ear"
style="@style/PodInfoItemIcon" style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_hearing_24" /> android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout> </LinearLayout>
@@ -52,12 +52,12 @@
<ImageView <ImageView
android:id="@+id/pod_case_icon" android:id="@+id/pod_case_icon"
style="@style/PodInfoItemIcon" style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_airpod_case_24" /> android:src="@drawable/ic_airpod_case_24" />
<TextView <TextView
android:id="@+id/pod_case_label" android:id="@+id/pod_case_label"
style="@style/TextAppearance.Compat.Notification.Title" style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
@@ -65,7 +65,7 @@
<ImageView <ImageView
android:id="@+id/pod_case_charging" android:id="@+id/pod_case_charging"
style="@style/PodInfoItemIcon" style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24" /> android:src="@drawable/ic_baseline_power_24" />
</LinearLayout> </LinearLayout>
@@ -81,12 +81,12 @@
<ImageView <ImageView
android:id="@+id/pod_right_icon" android:id="@+id/pod_right_icon"
style="@style/PodInfoItemIcon" style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_airpod_right_24" /> android:src="@drawable/ic_airpod_right_24" />
<TextView <TextView
android:id="@+id/pod_right_label" android:id="@+id/pod_right_label"
style="@style/TextAppearance.Compat.Notification.Title" style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
@@ -94,12 +94,12 @@
<ImageView <ImageView
android:id="@+id/pod_right_charging" android:id="@+id/pod_right_charging"
style="@style/PodInfoItemIcon" style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24" /> android:src="@drawable/ic_baseline_power_24" />
<ImageView <ImageView
android:id="@+id/pod_right_ear" android:id="@+id/pod_right_ear"
style="@style/PodInfoItemIcon" style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_hearing_24" /> android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout> </LinearLayout>
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal">
<TextView
android:id="@+id/headphones_label"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
tools:text="Beats Solo 3" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:orientation="horizontal">
<ImageView
android:id="@+id/headphones_battery_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_battery_unknown_24" />
<TextView
android:id="@+id/headphones_battery_label"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="8dp"
tools:text="100%" />
</LinearLayout>
</LinearLayout>

Some files were not shown because too many files have changed in this diff Show More