Compare commits

...
91 Commits
Author SHA1 Message Date
darken 788038beb7 Release: 2.14.1-rc0 2024-07-12 21:47:39 +02:00
darken 18ac05ed80 Fix crashes when entering settings
Likely from #209
2024-07-12 21:47:04 +02:00
darken 02f68821d2 Release: 2.14.0-rc0 2024-07-12 15:04:28 +02:00
darken 62886f00f1 Show battery values in notification content titles 2024-07-12 14:47:01 +02:00
darken f2eb45d00b Fix app crash on launch if permission are not granted
```java
12:49:29.433 AndroidRuntime                       E  FATAL EXCEPTION: main
                                                     Process: eu.darken.capod, PID: 15164
                                                     java.lang.SecurityException: Starting FGS with type connectedDevice callerApp=ProcessRecord{cb03698 15164:eu.darken.capod/u0a352} targetSDK=34 requires permissions: all of the permissions allOf=true [android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE] any of the permissions allOf=false [android.permission.BLUETOOTH_ADVERTISE, android.permission.BLUETOOTH_CONNECT, android.permission.BLUETOOTH_SCAN, android.permission.CHANGE_NETWORK_STATE, android.permission.CHANGE_WIFI_STATE, android.permission.CHANGE_WIFI_MULTICAST_STATE, android.permission.NFC, android.permission.TRANSMIT_IR, android.permission.UWB_RANGING, USB Device, USB Accessory]
                                                     	at android.os.Parcel.createExceptionOrNull(Parcel.java:3242)
                                                     	at android.os.Parcel.createException(Parcel.java:3226)
                                                     	at android.os.Parcel.readException(Parcel.java:3209)
                                                     	at android.os.Parcel.readException(Parcel.java:3151)
                                                     	at android.app.IActivityManager$Stub$Proxy.setServiceForeground(IActivityManager.java:7167)
                                                     	at android.app.Service.startForeground(Service.java:863)
                                                     	at androidx.work.impl.foreground.SystemForegroundService$Api31Impl.startForeground(SystemForegroundService.java:194)
                                                     	at androidx.work.impl.foreground.SystemForegroundService$1.run(SystemForegroundService.java:130)
                                                     	at android.os.Handler.handleCallback(Handler.java:959)
                                                     	at android.os.Handler.dispatchMessage(Handler.java:100)
                                                     	at android.os.Looper.loopOnce(Looper.java:232)
                                                     	at android.os.Looper.loop(Looper.java:317)
                                                     	at android.app.ActivityThread.main(ActivityThread.java:8699)
                                                     	at java.lang.reflect.Method.invoke(Native Method)
                                                     	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:580)
                                                     	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:886)
2024-07-12 13:44:57 +02:00
darken 2fca9097c2 Fix up-navigation behavior when doing onboarding
Back button shouldn't take you dashboard -> onboarding or onboarding -> dashboard
2024-07-12 13:44:44 +02:00
Ricky Cheung 051677e7dd Prevent return@apply duplicates
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-07-12 12:18:24 +02:00
Ricky Cheung fbcd595b45 Add more spacing around the wide variant of dual pods widgets
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-07-12 12:18:24 +02:00
Ricky Cheung aa38f58f62 setCustomContentView() -> setCustomBigContentView()
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-07-12 12:18:24 +02:00
Ricky Cheung a3b01a02ee Update all checkbox preferences to SUW switches
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-07-12 12:18:24 +02:00
Ricky Cheung 8544983984 Update com.google.android.material to 1.12.0
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-07-12 12:18:24 +02:00
Ricky Cheung 07a11799b1 Implement support for different states in notification text
Fixes #198

Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-27 08:34:19 +02:00
Kavish Devar 65a7b1933a Add PUSH_NOTIFICATIONS permission to AndroidManifest.xml for wearos (#203)
* Add PUSH_NOTIFICATIONS permissions to AndroidManifest.xml for wearos

Fixes https://github.com/d4rken-org/capod/issues/154

* Fix typo in AndroidManifest.xml for wearos app
2024-06-26 12:41:47 +02:00
Ricky Cheung bbfb735124 Enlarge the icons on wide widgets
* Also move the case details to the centre and separate the text a little

Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 7b474fb0e1 Fix up comment
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung c6524d7cee Log layout ID in createDualPodLayout
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 148cdf5109 Add more notes for the wide widgets
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 60a3039415 Annotate layout argument as LayoutRes
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 6f7b6e30c9 Fix status bar icons in light theme
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 08c095eae4 Switch all MaterialComponents for Material3 for Wear
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 9087be6282 Add wide variant of dual pod widget
* Wide variant activates when the widget is larger than 3 columns in length

Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung d46fe35eab Make widgets consistent with each other
Going by 2f9185efa7, it appears the label is preferred to be at the bottom

Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 1afb77ee33 Make the status bar transparent
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung a9215fc11c Switch to everything Material3 components
This commit modifies all major elements of the UI for phones to use Material3 elements, including the toolbar.

Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
darken d80a110280 Thank the MagicPods project 2024-04-30 16:10:06 +02:00
darken fdfc7f44fb Update translations 2024-04-30 15:37:18 +02:00
darken 26fbfc08f7 Remove proprietary Google Play info block in FOSS builds 2024-04-05 18:42:11 +02:00
hwlnx 89049ee3ab Update preferences_support.xml 2024-03-11 13:30:22 +01:00
hwlnx 68f81f900b Update strings.xml 2024-03-11 13:29:19 +01:00
darken 513bd891af Release: 2.13.0-rc0 2024-02-25 13:30:10 +01:00
darken 37cd753ab8 Update README 2024-02-25 13:29:13 +01:00
darken 34bf2311b4 Add support for PowerBeats4
Thanks to @docgalaxyblock
2024-02-25 13:29:13 +01:00
darken a86093f617 Update translations 2024-02-25 13:16:19 +01:00
Matthias Urhahn 71f66e6004 Merge pull request #182 from JerretQueen/main
Onboarding typo fixed, dual pods layout realigned.
2024-02-25 13:12:31 +01:00
Matthias Urhahn 2d1ab85e01 Update README.md 2024-02-25 13:11:46 +01:00
Matthias Urhahn f029eb9102 Merge pull request #179 from hwlnx/patch-1
Update strings.xml
2024-02-25 13:06:15 +01:00
Matthias Urhahn 471837ab88 Update gradle-wrapper.properties 2024-02-25 13:05:05 +01:00
Matthias Urhahn 1e80d742cc Update build.gradle.kts 2024-02-25 13:04:38 +01:00
Matthias Urhahn 16b6273938 Update build.gradle.kts 2024-02-25 13:04:08 +01:00
JerretQueen 3407370563 1. Fixed a typo in @string/onboarding_body2. The message now correctly says "implementation" instead of "implemtation".
2. Changed layout of overview_pods_dual_item.xml so that the left and right pod status are properly lined up with each other.
2023-12-30 00:14:24 -06:00
hwlnx 3cda73a36c Update strings.xml 2023-11-19 20:05:42 +01:00
darken b604c423ad Release: 2.12.1-rc0 2023-10-28 19:51:26 +02:00
darken 0c8b121c2c Request FOREGROUND_SERVICE_SPECIAL_USE for debug log service 2023-10-28 19:50:56 +02:00
darken da11961611 Release: 2.12.0-rc0 2023-10-28 18:36:11 +02:00
darken 48b9952fc0 Update translations 2023-10-28 18:21:50 +02:00
darken 99fa99eba5 Update billing client 2023-10-28 18:03:28 +02:00
darken 06e67e04ac Move permission 2023-10-28 17:58:09 +02:00
darken 4a2f7c20a2 Update unit test 2023-10-28 17:58:09 +02:00
darken 82e8769407 Bump dependencies and API targets. 2023-10-28 17:58:09 +02:00
darken ad38702df1 Widget: Center device label 2023-10-28 16:59:52 +02:00
darken 3f98c62a8f Extend test case for AirPods Pro 2 (USB-C)
Closes #164
2023-09-24 12:01:15 +02:00
darken ec7447992f Release: 2.11.1-rc0 2023-09-23 21:44:04 +02:00
Matthias Urhahn 99958e13eb Update translations (#166) 2023-09-23 21:43:24 +02:00
Matthias Urhahn b9fe5d4a14 Update README.md 2023-09-23 21:38:44 +02:00
darken 1443fd5714 Support for Apple AirPods Pro2 with USB-C
See #164
2023-09-23 21:35:30 +02:00
darken 798083b235 Remove link from changelog notes (eggshells) 2023-09-11 23:35:20 +02:00
darken 18a7311089 Release: 2.11.0-rc0 2023-09-11 22:45:47 +02:00
darken 9f49d62f12 Clean up store description and remove WearOS sentences. 2023-09-11 21:09:02 +02:00
darken 0c3c4e7ea1 Update translations 2023-09-11 21:09:02 +02:00
darken 7cdf653842 Remove WearOS part from app description, it's no longer on Google Play. 2023-09-11 21:09:02 +02:00
darken 32d99eada8 Prevent app from crashing if no app to view urls is installed 2023-09-11 20:57:15 +02:00
darken 9e4adb16c6 Hide install, not useful without 3rd party bug tracker 2023-09-11 20:33:06 +02:00
darken 4b5638e4f0 Hide bug reporting setting (but lets keep the setting ID because if we re-add we might use the setting state again). 2023-09-11 20:32:53 +02:00
darken d8fb9cdd30 Remove bugsnag 2023-09-11 20:32:53 +02:00
Matthias Urhahn 3920489dcd Extra onboarding step (#158)
* wip

* Add extra onboarding step
2023-09-11 20:32:41 +02:00
Matthias Urhahn 09d013588d Update PRIVACY_POLICY.md 2023-09-11 01:33:38 +02:00
darken d8bd03c65d Remove WearOS build from Google Play pipeline. 2023-07-16 01:05:04 +02:00
darken 8b2c6b9498 Release: 2.10.0-rc2 2023-07-07 10:13:48 +02:00
darken 0f63d3a0d4 Revert "Don't bump WearOS, not needed due to new track system?"
This reverts commit a321bb733c.
2023-07-07 10:12:56 +02:00
darken 672eb333b0 Update fastlane 2023-07-07 10:12:24 +02:00
darken de609b4d05 Release: 2.10.0-rc1 2023-07-07 09:05:38 +02:00
darken 7e6e9eee36 Try WearOS first as it is more likely to fail. 2023-07-07 09:04:34 +02:00
darken a321bb733c Don't bump WearOS, not needed due to new track system? 2023-07-07 09:04:34 +02:00
darken 490369a3e1 Adjust CI to build and upload to WearOS beta/production tracks 2023-07-07 09:04:34 +02:00
darken a2cce044ef Release: 2.10.0-rc0 2023-07-05 09:16:53 +02:00
darken 8884bb88c7 Update translations 2023-07-05 09:15:44 +02:00
darken 313b0e9131 Include changelog in changelog. 2023-07-05 09:15:44 +02:00
darken 47048cf7e7 Update translations 2023-07-05 09:15:44 +02:00
darken ef1c4615aa Add reaction option: Popup on new connection 2023-07-05 08:21:37 +02:00
Matthias Urhahn c9587b2bf0 Update translations (#147)
* Update translations

* Update translations
2023-07-04 12:41:14 +02:00
Sean Singh 669fa2fd22 Support edge-to-edge rendering on Android Q+ (#146)
* Set nav bars transparent in themes.xml files

* App renders edge-to-edge; need to handle scroll overlaps

* Add fake padding

* Begin version separating

* Edge-to-edge working everywhere except main page

* Finalized

* Cleanup Finalized

* Cleanup Finalized

* Removed redundant values-v29 & optimize imports

* Consolidated insets to Fragment2

* Consolidated insets to Fragment2 (file wasn't staged)
2023-07-04 12:17:44 +02:00
darken fb8ec72825 Release: 2.9.3-rc0 2023-06-27 22:07:22 +02:00
Matthias Urhahn d1687f574f Merge pull request #145 from d4rken-org/bump_workmanager
Fix background execution errors.
2023-06-27 22:04:28 +02:00
darken 72f47bec1f Fix background execution errors.
Bump workmanager `2.7.1` -> `2.8.1` to fix

```java
android.app.ForegroundServiceStartNotAllowedException: startForegroundService() not allowed due to mAllowStartForeground false: service eu.darken.capod/androidx.work.impl.foreground.SystemForegroundService
```
2023-06-27 21:55:58 +02:00
Matthias Urhahn 20e8aecf83 Merge pull request #144 from d4rken-org/gplay_hates_splash
Fix "Missing app icon in splash screen"
2023-06-27 21:49:50 +02:00
Matthias Urhahn ee41474670 Merge pull request #143 from d4rken-org/gplay_wear_policies_fml
Fix "Missing show time"
2023-06-27 21:49:37 +02:00
darken ac29e28eda Fix "Missing app icon in splash screen"
> Your app does not show a 48x48dp app icon on a black background during app startup. For more information, see Branded launch.
2023-06-27 21:49:05 +02:00
darken 58b96cbdee Fix "Missing show time"
> Your app does not display the time of day clearly at the top of the app home screen and any ongoing activity screens. We recommend that you display the time of day at the top of all activities except dialog and confirmation screens. For more information, see Show the time.
2023-06-27 21:12:16 +02:00
darken 0c1fc15251 Update translations 2023-06-27 20:28:31 +02:00
darken 7b8e0cc952 Update translations 2023-06-27 20:28:31 +02:00
darken c8aa591feb Update AGP 2023-06-27 20:28:21 +02:00
218 changed files with 2448 additions and 1087 deletions
+17 -5
View File
@@ -48,7 +48,6 @@ jobs:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }}
- name: Assemble production APK
if: "!contains(steps.tagger.outputs.tag, '-beta')"
@@ -58,7 +57,6 @@ jobs:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }}
- name: Create pre-release
if: contains(steps.tagger.outputs.tag, '-beta')
@@ -138,6 +136,14 @@ jobs:
ruby-version: 2.7.6
bundler-cache: true
# - name: Assemble WearOS beta and upload to Google Play
# if: contains(steps.tagger.outputs.tag, '-beta')
# run: bundle exec fastlane beta_wearos
# env:
# STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
# KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
# KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble beta and upload to Google Play
if: contains(steps.tagger.outputs.tag, '-beta')
run: bundle exec fastlane beta
@@ -145,7 +151,14 @@ jobs:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }}
# - name: Assemble WearOS production and upload to Google Play
# if: "!contains(steps.tagger.outputs.tag, '-beta')"
# run: bundle exec fastlane production_wearos
# env:
# STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
# KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
# KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble production and upload to Google Play
if: "!contains(steps.tagger.outputs.tag, '-beta')"
@@ -153,5 +166,4 @@ jobs:
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+42 -42
View File
@@ -1,27 +1,27 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.5)
CFPropertyList (3.0.6)
rexml
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
addressable (2.8.4)
public_suffix (>= 2.0.2, < 6.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-partitions (1.784.0)
aws-sdk-core (3.177.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.525.0)
aws-sigv4 (~> 1.1)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.58.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-kms (1.70.0)
aws-sdk-core (~> 3, >= 3.177.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.114.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-s3 (1.128.0)
aws-sdk-core (~> 3, >= 3.177.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.1)
aws-sigv4 (~> 1.6)
aws-sigv4 (1.6.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
@@ -30,14 +30,14 @@ GEM
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.4)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.7.6)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.92.4)
faraday (1.10.0)
excon (0.100.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@@ -65,8 +65,8 @@ GEM
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.6)
fastlane (2.208.0)
fastimage (2.2.7)
fastlane (2.213.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@@ -90,7 +90,7 @@ GEM
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (~> 2.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0)
@@ -106,9 +106,9 @@ GEM
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)
google-apis-androidpublisher_v3 (0.45.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@@ -117,27 +117,27 @@ GEM
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-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.19.0)
google-apis-core (>= 0.9.0, < 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)
google-cloud-errors (1.3.1)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.2.0)
googleauth (1.6.0)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
@@ -148,20 +148,20 @@ GEM
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)
jmespath (1.6.2)
json (2.6.3)
jwt (2.7.1)
memoist (0.16.2)
mini_magick (4.11.0)
mini_magick (4.12.0)
mini_mime (1.1.2)
multi_json (1.15.0)
multipart-post (2.0.0)
multipart-post (2.3.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)
plist (3.7.0)
public_suffix (5.0.1)
rake (13.0.6)
representable (3.2.0)
declarative (< 0.1.0)
@@ -178,7 +178,7 @@ GEM
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.8)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
@@ -194,7 +194,7 @@ GEM
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (1.8.0)
webrick (1.7.0)
webrick (1.8.1)
word_wrap (1.0.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
+28 -8
View File
@@ -20,23 +20,43 @@ On Android 12+ the newer and more fine grained `BLUETOOTH_SCAN` permission is us
Bluetooth Low Energy is a technology that devices like AirPods use to communicate their status to nearby devices.
CAPod requests location permissions because these permissions are required to work with Bluetooth Low Energy data.
This is a privacy measure on Android's side because you could determine someones location by scanning for Bluetooth devices:
If you know the physical location of a Bluetooth device (e.g. AirTags) you could use Bluetooth data to calculate your position.
This is a privacy measure on Android's side because you could determine someones location by scanning for Bluetooth
devices:
If you know the physical location of a Bluetooth device (e.g. AirTags) you could use Bluetooth data to calculate your
position.
### Location access in the background
CAPod uses the "location access in the background" permission (`ACCESS_BACKGROUND_LOCATION`) on Android 11 and older to receive Bluetooth Low Energy data while the app is in the background. This permission enables the "Show popup" and "Autoconnect" features and allows CAPod to react to nearby devices whil the app is closed.
CAPod uses the "location access in the background" permission (`ACCESS_BACKGROUND_LOCATION`) on Android 11 and older to
receive Bluetooth Low Energy data while the app is in the background. This permission enables the "Show popup" and "
Autoconnect" features and allows CAPod to react to nearby devices whil the app is closed.
## Automatic crash reports
## Automatic error reports
Anonymous device information may be collected in the event of an app crash or error.
*This was removed in v2.11.0+*
To do this the app uses the service "Bugsnag":
If an error occurs, an automated report may be sent to help me fix the issue.
This is optional and you can opt out of this in the settings.
Error reports are collected using "Bugsnag":
https://www.bugsnag.com/
Bugsnag's privacy policy can be found here:
https://docs.bugsnag.com/legal/privacy-policy/
Crash reports may contain device and app related information, e.g. your phone model, Android version and app version.
Error reports contain device and app information related to the error that occured.
Additional information about the error context may also be included, e.g. what this app did shortly before the error.
You can disable automatic reports in the app's settings.
Additional details about the type of data that is collected by the error tracking SDK can be found here:
https://docs.bugsnag.com/platforms/android/playstore-privacy/
Error reports are pseudonymous. Unless you tell me your install-ID, I don't know that an error report came from you.
Error reports are automatically deleted after 90 days.
## Debug logs
The app has a debug log feature that can be used to assist troubleshooting efforts. This feature creates a log file that contains verbose output of what the app is doing.
It is manually triggered by the user through an option in the app settings. The recorded log file can be shared through compatible apps (e.g. your email app) using the system's share dialog. As this log file may contain sensitive information (e.g. details about files or installed applications) it should only be shared with trusted parties.
+6 -1
View File
@@ -4,7 +4,8 @@
[![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)
[![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)
[![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)
[![Github Downloads](https://img.shields.io/github/downloads/d4rken-org/capod/total.svg?label=GitHub%20Downloads&logo=github)](https://github.com/d4rken-org/capod/edit/main/README.md#download)
[![Google Play Downloads](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Deu.darken.capod%26l%3DGoogle%2520Play%26m%3D%24totalinstalls)](https://github.com/d4rken-org/capod/edit/main/README.md#download)
A companion app that adds support for AirPod specific features to Android:
@@ -27,9 +28,11 @@ Currently supported models:
* AirPods 3. Generation
* AirPods Pro 1. Generation
* AirPods Pro 2. Generation
* AirPods Pro 2. Generation (USB-C)
* AirPods Max
* Power Beats Pro
* Power Beats 3
* Power Beats 4
* Beats Solo 3
* Beats Studio 3
* Beats X
@@ -75,6 +78,8 @@ Currently supported models:
Travis & Rye, Erik & Sipes, Brandon & Teplov, Sam. (2019). Handoff All Your Privacy A Review of Apples Bluetooth
Low Energy Continuity Protocol. Proceedings on Privacy Enhancing Technologies. 2019. 34-53. 10.2478/popets-2019-0057.
* Celosia, Guillaume. (2020). Privacy Challenges in Wireless Communications of the Internet of Things.
* The [MagicPods](https://github.com/steam3d/MagicPods-Windows) project. If you are looking for "CAPod for Windows",
check it out.
## License
+1 -1
View File
@@ -1 +1 @@
2.9.2-rc0 20902000
2.14.1-rc0 21401000
-7
View File
@@ -22,10 +22,6 @@ android {
buildConfigField("String", "APPLICATION_ID", "\"${ProjectConfig.packageName}\"")
buildConfigField("String", "GITSHA", "\"${lastCommitHash()}\"")
buildConfigField("String", "BUILDTIME", "\"${buildTime()}\"")
manifestPlaceholders["bugsnagApiKey"] = getBugSnagApiKey(
File(System.getProperty("user.home"), ".appconfig/${ProjectConfig.packageName}/bugsnag.properties")
) ?: "bugsnag_apikey_placeholder"
}
buildFeatures {
@@ -100,7 +96,4 @@ dependencies {
addTesting()
"gplayImplementation"("com.bugsnag:bugsnag-android:5.9.2")
"gplayImplementation"("com.getkeepsafe.relinker:relinker:1.4.3")
}
+2 -4
View File
@@ -1,10 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest>
<application>
<meta-data
android:name="com.bugsnag.android.API_KEY"
android:value="${bugsnagApiKey}" />
</application>
</manifest>
@@ -1,58 +0,0 @@
package eu.darken.capod.debug.autoreport
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import com.bugsnag.android.Event
import com.bugsnag.android.OnErrorCallback
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BugsnagErrorHandler @Inject constructor(
@ApplicationContext private val context: Context,
private val bugsnagLogger: BugsnagLogger,
private val debugSettings: DebugSettings,
) : OnErrorCallback {
override fun onError(event: Event): Boolean {
bugsnagLogger.injectLog(event)
TAB_APP.also { tab ->
event.addMetadata(tab, "gitSha", BuildConfigWrap.GIT_SHA)
event.addMetadata(tab, "buildTime", BuildConfigWrap.BUILDTIME)
context.tryFormattedSignature()?.let { event.addMetadata(tab, "signatures", it) }
}
return debugSettings.isAutoReportingEnabled.value && !eu.darken.capod.common.BuildConfigWrap.DEBUG
}
companion object {
private const val TAB_APP = "app"
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
fun Context.tryFormattedSignature(): String? = try {
packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES).signatures?.let { sigs ->
val sb = StringBuilder("[")
for (i in sigs.indices) {
sb.append(sigs[i].hashCode())
if (i + 1 != sigs.size) sb.append(", ")
}
sb.append("]")
sb.toString()
}
} catch (e: Exception) {
log(WARN) { e.asLog() }
null
}
}
}
@@ -1,47 +0,0 @@
package eu.darken.capod.debug.autoreport
import com.bugsnag.android.Event
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import java.lang.String.format
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BugsnagLogger @Inject constructor() : Logging.Logger {
// Adding one to the initial size accounts for the add before remove.
private val buffer: Deque<String> = ArrayDeque(BUFFER_SIZE + 1)
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
val line = "${System.currentTimeMillis()} ${priority.toLabel()}/$tag: $message"
synchronized(buffer) {
buffer.addLast(line)
if (buffer.size > BUFFER_SIZE) {
buffer.removeFirst()
}
}
}
fun injectLog(event: Event) {
synchronized(buffer) {
var i = 100
buffer.forEach { event.addMetadata("Log", format(Locale.ROOT, "%03d", i++), it) }
event.addMetadata("Log", format(Locale.ROOT, "%03d", i), event.originalError?.asLog())
}
}
companion object {
private const val BUFFER_SIZE = 200
private fun Logging.Priority.toLabel(): String = when (this) {
Logging.Priority.VERBOSE -> "V"
Logging.Priority.DEBUG -> "D"
Logging.Priority.INFO -> "I"
Logging.Priority.WARN -> "W"
Logging.Priority.ERROR -> "E"
Logging.Priority.ASSERT -> "WTF"
}
}
}
@@ -2,20 +2,15 @@ package eu.darken.capod.debug.autoreport
import android.app.Application
import android.content.Context
import com.bugsnag.android.Bugsnag
import com.bugsnag.android.Configuration
import com.getkeepsafe.relinker.ReLinker
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.InstallId
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
@Singleton
@@ -23,9 +18,6 @@ class GplayAutoReporting @Inject constructor(
@ApplicationContext private val context: Context,
private val debugSettings: DebugSettings,
private val installId: InstallId,
private val bugsnagLogger: Provider<BugsnagLogger>,
private val bugsnagErrorHandler: Provider<BugsnagErrorHandler>,
private val nopBugsnagErrorHandler: Provider<NOPBugsnagErrorHandler>,
) : AutomaticBugReporter {
override fun setup(application: Application) {
@@ -34,35 +26,13 @@ class GplayAutoReporting @Inject constructor(
if (!isEnabled) return
ReLinker
.log { message -> log(TAG) { "ReLinker: $message" } }
.loadLibrary(application, "bugsnag-plugin-android-anr")
// Currently no 3rd party bug tracking
try {
val bugsnagConfig = Configuration.load(context).apply {
if (debugSettings.isAutoReportingEnabled.value) {
Logging.install(bugsnagLogger.get())
setUser(installId.id, null, null)
autoTrackSessions = true
addOnError(bugsnagErrorHandler.get())
addMetadata("App", "buildFlavor", BuildConfigWrap.FLAVOR)
log(TAG) { "Bugsnag setup done!" }
} else {
autoTrackSessions = false
addOnError(nopBugsnagErrorHandler.get())
log(TAG) { "Installing Bugsnag NOP error handler due to user opt-out!" }
}
}
Bugsnag.start(context, bugsnagConfig)
Bugs.reporter = this
} catch (e: IllegalStateException) {
log(TAG) { "Bugsnag API Key not configured." }
}
Bugs.reporter = this
}
override fun notify(throwable: Throwable) {
Bugsnag.notify(throwable)
log(TAG, WARN) { "notify($throwable)" }
}
companion object {
@@ -1,19 +0,0 @@
package eu.darken.capod.debug.autoreport
import com.bugsnag.android.Event
import com.bugsnag.android.OnErrorCallback
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NOPBugsnagErrorHandler @Inject constructor() : OnErrorCallback {
override fun onError(event: Event): Boolean {
log(WARN) { "Error, but skipping bugsnag due to user opt-out: ${event.originalError?.asLog()}" }
return false
}
}
@@ -0,0 +1,15 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothDevice
import java.time.Instant
data class BluetoothDevice2(
internal val internal: BluetoothDevice,
val seenFirstAt: Instant,
) {
val address: String
get() = internal.address
val name: String?
get() = internal.name
}
@@ -1,6 +1,10 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.*
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.BluetoothLeScanner
import android.content.BroadcastReceiver
import android.content.Context
@@ -12,14 +16,24 @@ import android.os.ParcelUuid
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.IOException
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -74,7 +88,7 @@ class BluetoothManager2 @Inject constructor(
}
override fun onServiceDisconnected(profile: Int) {
log(TAG, WARN) { "onServiceDisconnected(profile=$profile" }
log(TAG, WARN) { "onServiceDisconnected(profile=$profile)" }
close(IOException("BluetoothProfile service disconnected (profile=$profile)"))
}
@@ -128,6 +142,7 @@ class BluetoothManager2 @Inject constructor(
log(TAG) { "Adding $device to current devices $currentDevices" }
trySend(currentDevices.plus(device))
}
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
log(TAG) { "Removing $device from current devices $currentDevices" }
trySend(currentDevices.minus(device))
@@ -145,23 +160,54 @@ class BluetoothManager2 @Inject constructor(
}
}
private val seenDevicesLock = Mutex()
private val seenDevicesCache = mutableMapOf<String, Instant>()
fun connectedDevices(
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
): Flow<List<BluetoothDevice>> = isBluetoothEnabled
): Flow<List<BluetoothDevice2>> = isBluetoothEnabled
.flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
.map { devices ->
devices.filter { device ->
featureFilter.any { feature ->
device.hasFeature(feature)
}
val currentAddresses = devices.map { it.address }
seenDevicesLock.withLock {
val cleanedCache = seenDevicesCache.filterKeys { currentAddresses.contains(it) }
seenDevicesCache.clear()
seenDevicesCache.putAll(cleanedCache)
}
devices
.filter { device -> featureFilter.any { feature -> device.hasFeature(feature) } }
.map { device ->
BluetoothDevice2(
internal = device,
seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: Instant.now().also {
seenDevicesCache[device.address] = it
}
}
)
}
}
fun bondedDevices(): Flow<Set<BluetoothDevice>> = flow {
emit(adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable"))
fun bondedDevices(): Flow<Set<BluetoothDevice2>> = flow {
val rawDevices = adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable")
val wrappedDevices = rawDevices.map { device ->
BluetoothDevice2(
internal = device,
seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: Instant.now().also {
seenDevicesCache[device.address] = it
}
}
)
}.toSet()
emit(wrappedDevices)
}
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = getBluetoothProfile().map { bluetoothProfile ->
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
@@ -169,7 +215,7 @@ class BluetoothManager2 @Inject constructor(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
connectMethod.invoke(bluetoothProfile.proxy, device)
connectMethod.invoke(bluetoothProfile.proxy, device.internal)
log(TAG) { "Nudged connection to $device" }
true
@@ -0,0 +1,14 @@
package eu.darken.capod.common.preferences
import android.content.Context
import android.util.AttributeSet
import androidx.preference.SwitchPreferenceCompat
class MaterialSwitchPreference(context: Context, attrs: AttributeSet?) :
SwitchPreferenceCompat(context, attrs) {
init {
// Use material switch
widgetLayoutResource = eu.darken.capod.common.R.layout.preference_material_switch
}
}
@@ -3,6 +3,7 @@ package eu.darken.capod.common.uix
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.lifecycle.LiveData
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
@@ -15,6 +16,8 @@ abstract class Activity2 : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
log(tag, VERBOSE) { "onCreate(savedInstanceState=$savedInstanceState)" }
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows( window, false )
}
override fun onResume() {
@@ -7,6 +7,8 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
@@ -40,6 +42,17 @@ abstract class Fragment2(@LayoutRes val layoutRes: Int?) : Fragment(layoutRes ?:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
log(tag, VERBOSE) { "onViewCreated(view=$view, savedInstanceState=$savedInstanceState)" }
super.onViewCreated(view, savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val systemWindowInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(
v.paddingLeft,
systemWindowInsets.top,
v.paddingRight,
0
)
insets
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
@@ -8,6 +8,7 @@ import eu.darken.capod.common.error.ErrorEventSource
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.navigation.NavEventSource
import eu.darken.capod.common.navigation.navVia
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
@@ -31,4 +32,7 @@ abstract class ViewModel3(
.setupCommonEventHandlers(TAG) { "launchInViewModel()" }
.launchIn(vmScope)
fun NavDirections.navigate() {
navVia(navEvents)
}
}
@@ -40,6 +40,8 @@ class GeneralSettings @Inject constructor(
val isOffloadedBatchingDisabled = preferences.createFlowPreference("core.compat.offloaded.batching.disabled", false)
val useIndirectScanResultCallback = preferences.createFlowPreference("core.compat.indirectcallback.enabled", false)
val isOnboardingDone = preferences.createFlowPreference("core.onboarding.done", false)
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
monitorMode,
scannerMode,
@@ -10,7 +10,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import java.time.Duration
import java.time.Instant
import java.util.*
import java.util.UUID
import kotlin.math.abs
import kotlin.math.max
@@ -91,6 +91,10 @@ interface PodDevice {
"AirPods Pro 2",
R.drawable.devic_airpods_pro2_both
),
@Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC(
"AirPods Pro 2 USB-C",
R.drawable.devic_airpods_pro2_both
),
@Json(name = "airpods.max") AIRPODS_MAX(
"AirPods Max",
R.drawable.devic_headphones_generic
@@ -110,6 +114,9 @@ interface PodDevice {
@Json(name = "beats.powerbeats.3") POWERBEATS_3(
"Power Beats 3"
),
@Json(name = "beats.powerbeats.4") POWERBEATS_4(
"Power Beats 4"
),
@Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro"
),
@@ -19,6 +19,7 @@ abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory<out ApplePods>
@@ -26,6 +27,7 @@ abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeats4(factory: PowerBeats4.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory<out ApplePods>
@@ -0,0 +1,89 @@
package eu.darken.capod.pods.core.apple.airpods
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class AirPodsPro2Usbc(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO2_USBC
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super<HasStateDetectionAirPods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsPro2Usbc(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,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
}
companion object {
private val DEVICE_CODE = 0x2420.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro2", "USBC")
}
}
@@ -0,0 +1,60 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class PowerBeats4(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_4
override val rssi: Int
get() = rssiAverage ?: super<SingleApplePods>.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = PowerBeats4(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,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
reliability = result.reliability,
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
}
companion object {
private val DEVICE_CODE = 0x0D20.toUShort()
private val TAG = logTag("PodDevice", "Beats", "PowerBeats", "4")
}
}
@@ -47,6 +47,11 @@ class ReactionSettings @Inject constructor(
false
)
val showPopUpOnConnection = preferences.createFlowPreference(
"reaction.popup.connected",
false
)
val onePodMode = preferences.createFlowPreference(
"reaction.onepod.enabled",
false
@@ -58,6 +63,7 @@ class ReactionSettings @Inject constructor(
autoConnect,
autoConnectCondition,
showPopUpOnCaseOpen,
showPopUpOnConnection,
onePodMode,
)
}
@@ -1,15 +1,26 @@
package eu.darken.capod.reaction.core.popup
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.withPrevious
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
@@ -19,11 +30,13 @@ import javax.inject.Singleton
class PopUpReaction @Inject constructor(
private val podMonitor: PodMonitor,
private val reactionSettings: ReactionSettings,
private val generalSettings: GeneralSettings,
private val bluetoothManager: BluetoothManager2,
) {
private val coolDowns = mutableMapOf<PodDevice.Id, Instant>()
private val caseCoolDowns = mutableMapOf<PodDevice.Id, Instant>()
fun monitor(): Flow<Event> = reactionSettings.showPopUpOnCaseOpen.flow
private fun monitorCase(): Flow<Event> = reactionSettings.showPopUpOnCaseOpen.flow
.flatMapLatest { isEnabled ->
if (isEnabled) {
podMonitor.mainDevice.distinctUntilChangedBy { it?.rawDataHex }
@@ -32,7 +45,7 @@ class PopUpReaction @Inject constructor(
}
}
.withPrevious()
.setupCommonEventHandlers(TAG) { "monitor" }
.setupCommonEventHandlers(TAG) { "popUpCase" }
.mapNotNull { (previous, current) ->
if (previous !is DualApplePods? || current !is DualApplePods) {
return@mapNotNull null
@@ -54,35 +67,37 @@ class PopUpReaction @Inject constructor(
}
log(TAG) { "Case lid status changed for monitored device." }
tryPopWindow(current)
throttleCasePopUps(current)
}
private suspend fun tryPopWindow(current: DualApplePods): Event? = when {
private fun throttleCasePopUps(current: DualApplePods): Event? = when {
current.caseLidState == DualApplePods.LidState.OPEN -> {
log(TAG, INFO) { "Show popup" }
val now = Instant.now()
val lastShown = coolDowns[current.identifier] ?: Instant.MIN
val lastShown = caseCoolDowns[current.identifier] ?: Instant.MIN
val sinceLastPop = Duration.between(lastShown, now)
log(TAG) { "Time since last popup: $sinceLastPop" }
log(TAG) { "Time since last case popup: $sinceLastPop" }
if (sinceLastPop >= Duration.ofSeconds(10)) {
coolDowns[current.identifier] = Instant.now()
caseCoolDowns[current.identifier] = Instant.now()
Event.PopupShow(device = current)
} else {
log(TAG, INFO) { "Popup is still on cooldown: $sinceLastPop" }
log(TAG, INFO) { "Case popup is still on cooldown: $sinceLastPop" }
null
}
}
current.caseLidState != DualApplePods.LidState.OPEN -> {
when (current.caseLidState) {
DualApplePods.LidState.CLOSED -> {
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
coolDowns.remove(current.identifier)
caseCoolDowns.remove(current.identifier)
}
else -> {
log(TAG, WARN) { "Lid was was not actively closed, refreshing cooldown." }
coolDowns[current.identifier] = Instant.now()
caseCoolDowns[current.identifier] = Instant.now()
}
}
@@ -90,9 +105,78 @@ class PopUpReaction @Inject constructor(
Event.PopupHide()
}
else -> null
}
private val connectionCoolDowns = mutableMapOf<String, Instant>()
private fun monitorConnection(): Flow<Event> = reactionSettings.showPopUpOnConnection.flow
.flatMapLatest { isEnabled ->
if (!isEnabled) return@flatMapLatest emptyFlow()
combine(
generalSettings.mainDeviceAddress.flow,
bluetoothManager.connectedDevices().distinctUntilChanged(),
podMonitor.mainDevice.distinctUntilChangedBy { it?.rawDataHex },
) { targetAddress, devices, broadcast ->
log(TAG) { "$targetAddress $broadcast $devices " }
val direct = devices.singleOrNull { it.address == targetAddress }.also {
log(TAG, VERBOSE) { "Connected main device is $it" }
}
if (direct == null) {
connectionCoolDowns.remove(targetAddress).also {
if (it != null) log(TAG) { "Cleared connection cooldown for $targetAddress due to disconect" }
}
}
if (direct != null && broadcast != null) direct to broadcast else null
}
}
.withPrevious()
.mapNotNull { (previouss, currents) ->
val previousConnected = previouss?.first
log(TAG, VERBOSE) { "previousConnected: $previousConnected" }
val previousBroadcasted = previouss?.second
log(TAG, VERBOSE) { "previousBroadcasted: $previousBroadcasted" }
val currentConnected = currents?.first
log(TAG, VERBOSE) { "currentConnected: $currentConnected" }
val currentBroadcasted = currents?.second
log(TAG, VERBOSE) { "currentBroadcasted: $currentBroadcasted" }
if (previousConnected != null && previousBroadcasted != null && currentConnected == null) {
return@mapNotNull Event.PopupHide()
}
if (currentConnected == null || currentBroadcasted == null) {
// We need an active connection
return@mapNotNull null
}
val ageOfBroadcastedDevice = Duration.between(Instant.now(), currentBroadcasted.seenFirstAt)
val ageOfConnectedDevice = Duration.between(Instant.now(), currentConnected.seenFirstAt)
if (ageOfBroadcastedDevice > (ageOfConnectedDevice + Duration.ofSeconds(30))) {
// This is likely a false positive, some random nearby device
// We expect the first broadcasts to not be much older than the first connection
log(TAG, VERBOSE) { "Current broadcasted main device is probably a false-positive" }
return@mapNotNull null
}
val now = Instant.now()
val lastShown = connectionCoolDowns[currentConnected.address]
val sinceLastPop = lastShown?.let { Duration.between(it, now) }
log(TAG) { "Time since last connection popup: ${sinceLastPop?.seconds}s" }
if (lastShown == null) {
connectionCoolDowns[currentConnected.address] = Instant.now()
Event.PopupShow(device = currentBroadcasted)
} else {
log(TAG) { "Connection popup is still on cooldown: $sinceLastPop" }
null
}
}
.setupCommonEventHandlers(TAG) { "popUpConnection" }
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection())
sealed class Event {
data class PopupShow(
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.materialswitch.MaterialSwitch xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/switchWidget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@null"
android:clickable="false"
android:focusable="false" />
@@ -58,4 +58,6 @@
<string name="pods_case_unknown_state">حالة غير معروفة</string>
<string name="last_seen_x">آخر ظهور: %s</string>
<string name="first_seen_x">أول ظهور:%s</string>
<string name="permission_post_notifications_label"></string>
<string name="permission_post_notifications_description">"اسمح لـلتطبيق بإظهار الإشعارات المتعلقة بسماعات الأذن الخاصة بك، على سبيل المثال. حالتهم الحالية خلال الاتصال."</string>
</resources>
@@ -58,6 +58,6 @@
<string name="pods_case_unknown_state">Невядомы стан</string>
<string name="last_seen_x">Апошні раз быў: %s</string>
<string name="first_seen_x">Упершыню быў: %s</string>
<string name="permission_post_notifications_label">Адлюстраванне апавяшчэнняў</string>
<string name="permission_post_notifications_description">"Дазволіць CAPod адлюстроўваць вам апавяшчэнні аб вашых AirPods: напрыклад, іх бягучы стан пры падключэнні."</string>
<string name="permission_post_notifications_label">Паказваць апавяшчэнні</string>
<string name="permission_post_notifications_description">"Дазволіць CAPod адлюстроўваць апавяшчэнні аб вашых AirPods: напрыклад, іх бягучы стан пры падключэнні."</string>
</resources>
@@ -58,6 +58,6 @@
<string name="pods_case_unknown_state">Estat desconegut</string>
<string name="last_seen_x">Últim cop vist: %s</string>
<string name="first_seen_x">Primera vegada vist: %s</string>
<string name="permission_post_notifications_label">Publica notificacions</string>
<string name="permission_post_notifications_description">"Permet que el CAPod us mostri notificacions sobre els vostres AirPods, p. ex. el seu estat actual mentre estan connectats."</string>
<string name="permission_post_notifications_label">Mostra notificacions</string>
<string name="permission_post_notifications_description">"Permet que el CAPod mostri notificacions sobre els vostres AirPods, p. ex. el seu estat actual mentre estan connectats."</string>
</resources>
@@ -58,6 +58,6 @@
<string name="pods_case_unknown_state">Neznámý stav</string>
<string name="last_seen_x">Naposledy spatřeno: %s</string>
<string name="first_seen_x">Poprvé spatřeno: %s</string>
<string name="permission_post_notifications_label">Posílat zprávy</string>
<string name="permission_post_notifications_label">Zobrazit oznámení</string>
<string name="permission_post_notifications_description">"Povolte aplikaci CAPod zobrazovat oznámení o sluchátkách AirPods, např. o jejich aktuálním stavu během připojení."</string>
</resources>
@@ -58,6 +58,6 @@
<string name="pods_case_unknown_state">Unbekannter Zustand</string>
<string name="last_seen_x">Zuletzt gesehen: %s</string>
<string name="first_seen_x">Zum ersten Mal gesehen: %s</string>
<string name="permission_post_notifications_label">Benachrichtigungen posten</string>
<string name="permission_post_notifications_description">"Erlauben Sie CAPod, Ihnen Benachrichtigungen über Ihre AirPods anzuzeigen, z.b. ihren aktuellen Status, während sie verbunden sind."</string>
<string name="permission_post_notifications_label">Zeige Benachrichtigungen</string>
<string name="permission_post_notifications_description">"Erlaube CAPod, Benachrichtigungen über deine AirPods anzuzeigen, z. B. deren aktuellen Status, während die Verbindung besteht."</string>
</resources>
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">Δε διατίθεται</string>
<string name="general_error_label">Σφάλμα</string>
<string name="general_grant_permission_action">Παραχώρηση άδειας</string>
@@ -56,4 +58,6 @@
<string name="pods_case_unknown_state">Άγνωστη κατάσταση</string>
<string name="last_seen_x">Τελευταία εμφάνιση: %s</string>
<string name="first_seen_x">Πρώτη εμφάνιση: %s</string>
<string name="permission_post_notifications_label">Εμφάνιση ειδοποιήσεων</string>
<string name="permission_post_notifications_description">"Επιτρέψτε στην εφαρμογή να εμφανίζει ειδοποιήσεις σχετικά με τα AirPods σας, π.χ. την τρέχουσα κατάστασή τους ενώ είστε συνδεδεμένοι."</string>
</resources>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_grant_permission_action">Conceder permiso</string>
<string name="permission_bluetooth_connect_label">Conexión Bluetooth</string>
<string name="permission_bluetooth_connect_description">Esta aplicacion requiere el permiso \"Conexión Bluetooth\" para interactuar con los dispositivos vinculados e iniciar las conexiones.</string>
@@ -58,6 +58,6 @@
<string name="pods_case_unknown_state">Estado desconocido</string>
<string name="last_seen_x">Visto por última vez: %s</string>
<string name="first_seen_x">Visto por primera vez: %s</string>
<string name="permission_post_notifications_label">Publicar las notificaciones</string>
<string name="permission_post_notifications_description">"Permite que CAPod te muestre notificaciones sobre tus AirPods, por ejemplo, su estado actual mientras están conectados."</string>
<string name="permission_post_notifications_label">Mostrar las notificaciones</string>
<string name="permission_post_notifications_description">"Permite que CAPod muestre las notificaciones sobre tus AirPods, por ejemplo, su estado actual mientras están conectados."</string>
</resources>
@@ -6,5 +6,6 @@
<string name="general_value_not_available_label">Pole saadaval</string>
<string name="pods_dual_left_label">Vasak klapp</string>
<string name="pods_dual_right_label">Parem klapp</string>
<string name="permission_post_notifications_label">Näita teateid</string>
<string name="permission_post_notifications_description">"Luba CAPodil näidata AirPodsi teateid, nt praegust olekut ühendatud olles."</string>
</resources>
@@ -19,12 +19,12 @@
<string name="permission_access_fine_location_label">Accéder à la position précise</string>
<string name="permission_access_fine_location_description">CAPod utilise lautorisation « position précise » pour recevoir les données « faible énergie » de Bluetooth. Votre casque-micro utilise la technologie « basse énergie » de Bluetooth pour diffuser son état. Cette appli nutilisera PAS les données Bluetooth pour déterminer votre position.</string>
<string name="permission_background_location_label">Accès à la position en arrière-plan</string>
<string name="permission_background_location_description">CAPod utilise « laccès à la position en arrière-plan » afin de permettre des fonctions telles qu’« Afficher une notification » et « Connexion automatique » alors que lappli est fermée. Laccès à la position en arrière-plan permet à lappli de recevoir les données « basse énergie » de Bluetooth. Elles ne seront PAS utilisées pour déterminer votre position géographie.</string>
<string name="permission_background_location_description">CAPod utilise « laccès à la position en arrière-plan » afin de permettre des fonctions telles qu’« Afficher une fenêtre surgissante » et « Connexion automatique » alors que lappli est fermée. Laccès à la position en arrière-plan permet à lappli de recevoir les données « basse énergie » de Bluetooth. Elles ne seront PAS utilisées pour déterminer votre position géographie.</string>
<string name="permission_ignore_battery_optimizations_label">Désactiver les optimisations de la pile/batterie</string>
<string name="permission_ignore_battery_optimizations_description">Les optimisations de la pile/batterie empêchent lappli de recevoir les données Bluetooth de manière fiable alors que lappli est en arrière-plan.</string>
<string name="permission_required_title">Lautorisation suivante est nécessaire :</string>
<string name="permission_system_alert_window_label">Fenêtre dalerte système</string>
<string name="permission_system_alert_window_description">Permettre à CAPod dapparaître par-dessus les autres applis pour rendre la fonction « Afficher une notification » possible.</string>
<string name="permission_system_alert_window_description">Permettre à CAPod dapparaître par-dessus les autres applis pour rendre la fonction « Afficher une fenêtre surgissante » possible.</string>
<string name="settings_scanner_mode_lowpower_label">Faible puissance</string>
<string name="settings_scanner_mode_balanced_label">Équilibré</string>
<string name="settings_scanner_mode_lowlatency_label">Faible latence</string>
@@ -58,6 +58,4 @@
<string name="pods_case_unknown_state">अज्ञात अवस्था</string>
<string name="last_seen_x">अंतिम बार देखे गए: %s</string>
<string name="first_seen_x">पहली बार देखा गया: %s</string>
<string name="permission_post_notifications_label">सूचनाएं जारी करें</string>
<string name="permission_post_notifications_description">"CAPod को अपने AirPods के बारे में सूचनाएं दिखाने की अनुमति दें, जैसे कि जुड़े रहने के दौरान उनकी वर्तमान स्थिति।"</string>
</resources>
@@ -58,6 +58,6 @@
<string name="pods_case_unknown_state">Status tidak diketahui</string>
<string name="last_seen_x">Terakhir terlihat: %s</string>
<string name="first_seen_x">Pertama kali terlihat: %s</string>
<string name="permission_post_notifications_label">Posting pemberitahuan</string>
<string name="permission_post_notifications_description">"Izinkan CAPod menampilkan pemberitahuan tentang AirPods Anda, mis. status saat terhubung."</string>
<string name="permission_post_notifications_label">Tampilkan notifikasi</string>
<string name="permission_post_notifications_description">"Izinkan CAPod untuk menampilkan notifikasi tentang AirPods anda, contoh. status saat terhubung."</string>
</resources>
+11 -9
View File
@@ -21,26 +21,26 @@
<string name="permission_background_location_label">Accesso in background alla tua posizione</string>
<string name="permission_background_location_description">CAPod usa l\'accesso in background alla tua posizione per far funzionare opzioni come \"Mostra popup\" e \"AutoConnect\" mentre l\'applicazione é chiusa. L\'accesso in background abiliterà l\'applicazione nella ricezione di dati Bluetooth a basso consume mentre l\'app non é aperta. Questa applicazione non utilizza i dati Bluetooth per geolocalizzarti.</string>
<string name="permission_ignore_battery_optimizations_label">Disabilita ottimizzazione batteria</string>
<string name="permission_ignore_battery_optimizations_description">L\'ottimizzazione della batteria impedisce a questa applicazione di ricevere dati Bluetooth mentre l\'app é chiusa.</string>
<string name="permission_ignore_battery_optimizations_description">L\'ottimizzazione della batteria impedisce a questa applicazione di ricevere dati Bluetooth mentre l\'app è in esecuzione in background.</string>
<string name="permission_required_title">È richiesta la seguente autorizzazione:</string>
<string name="permission_system_alert_window_label">Finestra di avviso di sistema</string>
<string name="permission_system_alert_window_description">Permetti a CAPod di far apprarire overlay sopra le altre applicazioni per far funzionare \"Mostra PopUp\".</string>
<string name="permission_system_alert_window_description">Concedi a CAPod la visualizzazione in primo piano sopra le altre applicazioni per la funzione \"Mostra PopUp\".</string>
<string name="settings_scanner_mode_lowpower_label">Basso consumo</string>
<string name="settings_scanner_mode_balanced_label">Bilanciato</string>
<string name="settings_scanner_mode_lowlatency_label">Bassa latenza</string>
<string name="settings_monitor_mode_manual_label">Quando l\'applicazione è aperta</string>
<string name="settings_monitor_mode_automatic_label">Quando il device è connesso</string>
<string name="settings_monitor_mode_automatic_label">Quando le cuffie sono connesse</string>
<string name="settings_monitor_mode_always_label">Sempre</string>
<string name="settings_reaction_autoconnect_whenseen_label">Quando rilevato</string>
<string name="settings_reaction_autoconnect_caseopen_label">La custodia é aperta</string>
<string name="settings_reaction_autoconnect_inear_label">Nell\'orecchio</string>
<string name="pods_dual_left_label">Pod sinistra</string>
<string name="pods_dual_right_label">Pod destra</string>
<string name="settings_reaction_autoconnect_inear_label">Indossato</string>
<string name="pods_dual_left_label">Pod SX</string>
<string name="pods_dual_right_label">Pod DX</string>
<string name="pods_case_label">Custodia</string>
<string name="pods_case_status_open_label">Aperto</string>
<string name="pods_case_status_closed_label">Chiuso</string>
<string name="pods_connection_state_disconnected_label">Non connesso</string>
<string name="pods_connection_state_idle_label">Connesso ad un device, ma inattivo</string>
<string name="pods_connection_state_idle_label">Connesso al dispositivo, ma inattivo</string>
<string name="pods_connection_state_music_label">In modalità musica</string>
<string name="pods_connection_state_call_label">In modalità chiamata</string>
<string name="pods_connection_state_ringing_label">Squilla</string>
@@ -48,14 +48,16 @@
<string name="pods_connection_state_unknown_label">Stato di connessione sconosciuto</string>
<string name="pods_unknown_raw_data_label">Dati non elaborati</string>
<string name="pods_unknown_label">Dispositivo sconosciuto</string>
<string name="pods_unknown_contact_dev">Questo é un dispositivo sconosciutom ma sta utilizzando un formato di comunicazione noto. Aggiungiamo il supporto per questo device, Contattami! :)</string>
<string name="pods_unknown_contact_dev">Questo è un dispositivo sconosciuto, ma sta utilizzando un formato di comunicazione noto. Aggiungiamo il supporto per questo dispositivo, contattami :)</string>
<string name="pods_none_label_short">Nessun dispositivo</string>
<string name="pods_charging_label">In ricarica</string>
<string name="pods_inear_label">Nell\'orecchio</string>
<string name="pods_inear_label">Indossato</string>
<string name="pods_microphone_label">Microfono</string>
<string name="pods_yours">Tuoi</string>
<string name="headset_being_worn_label">Indossato</string>
<string name="pods_case_unknown_state">Stato sconosciuto</string>
<string name="last_seen_x">Ultimo accesso: %s</string>
<string name="first_seen_x">Primo accesso: %s</string>
<string name="permission_post_notifications_label">Mostra notifiche</string>
<string name="permission_post_notifications_description">"Concedi a CAPod di mostrare notifiche riguardanti le tue AirPods, ad esempio il loro stato di carica mentre sono connesse."</string>
</resources>
@@ -58,6 +58,4 @@
<string name="pods_case_unknown_state">מצב לא ידוע</string>
<string name="last_seen_x">נראה לאחרונה: %s</string>
<string name="first_seen_x">נראה לראשונה: %s</string>
<string name="permission_post_notifications_label">הודעות פרסום</string>
<string name="permission_post_notifications_description">"לאשר ל-CAPod להראות לך התראות על ה-AirPods שלך, למשל. המצב הנוכחי שלהם בזמן שהם מחוברים."</string>
</resources>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">T/A</string>
<string name="general_error_label">Ralat</string>
<string name="general_grant_permission_action">Berikan kebenaran</string>
@@ -55,6 +58,6 @@
<string name="pods_case_unknown_state">Keadaan tidak diketahui</string>
<string name="last_seen_x">Terakhir dilihat: %s</string>
<string name="first_seen_x">Pertama dilihat: %s</string>
<string name="permission_post_notifications_label">Siarkan pemberitahuan</string>
<string name="permission_post_notifications_description">"Benarkan CAPod menunjukkan pemberitahuan kepada anda tentang AirPods anda, mis. status semasa mereka semasa disambungkan."</string>
<string name="permission_post_notifications_label">Tunjukkan pemberitahuanm</string>
<string name="permission_post_notifications_description">"Benarkan CAPod memaparkan pemberitahuan tentang AirPods anda, misalnya, status semasa mereka ketika disambungkan."</string>
</resources>
@@ -58,6 +58,6 @@
<string name="pods_case_unknown_state">Onbekende staat</string>
<string name="last_seen_x">Laatst gezien: %s</string>
<string name="first_seen_x">Eerst gezien: %s</string>
<string name="permission_post_notifications_label">Post notificaties</string>
<string name="permission_post_notifications_description">"Sta CAPod toe notificaties over je AirPods te tonen, b.v. de huidige verbindingsstatus."</string>
<string name="permission_post_notifications_label">Toon meldingen</string>
<string name="permission_post_notifications_description">"Sta CAPod toe om meldingen over je AirPods weer te geven, bijv. hun huidige status terwijl ze verbonden zijn."</string>
</resources>
@@ -58,6 +58,6 @@
<string name="pods_case_unknown_state">Nieznany stan</string>
<string name="last_seen_x">Ostatnio widziane: %s</string>
<string name="first_seen_x">Widziane pierwszy raz: %s</string>
<string name="permission_post_notifications_label">Wysyłanie powiadomień</string>
<string name="permission_post_notifications_label">Wyświetl powiadomienia</string>
<string name="permission_post_notifications_description">"Zezwól CAPod na wyświetlanie powiadomień o słuchawkach AirPods, np. ich aktualnym statusie podczas połączenia."</string>
</resources>
@@ -58,6 +58,6 @@
<string name="pods_case_unknown_state">Estado desconhecido</string>
<string name="last_seen_x">Visto pela última vez: %s</string>
<string name="first_seen_x">Visto pela primeira vez: %s</string>
<string name="permission_post_notifications_label">Post de notificações</string>
<string name="permission_post_notifications_label">Mostrar notificações</string>
<string name="permission_post_notifications_description">"Permita que o CAPod mostre notificações sobre seus AirPods, por exemplo. seu status atual enquanto conectado."</string>
</resources>
+62 -1
View File
@@ -1,2 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"></resources>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_error_label">Eroare</string>
<string name="general_grant_permission_action">Acordați permisiuni</string>
<string name="overview_nomaindevice_label">Niciun dispozitiv principal</string>
<string name="overview_nomaindevice_description">Este puțin probabil ca toate dispozitivele detectate să fie ale tale. Porniți și conectați dispozitivul sau ajustați setările.</string>
<string name="overview_bluetooth_disabled_label">Bluetooth este dezactivat</string>
<string name="overview_bluetooth_disabled_description">Bluetooth este dezactivat, activați-l ;)</string>
<string name="permission_bluetooth_connect_label">Conectare Bluetooth</string>
<string name="permission_bluetooth_connect_description">Această aplicație necesită permisiunea „Conectare Bluetooth” pentru a interacționa cu dispozitivele asociate și a iniția conexiuni.</string>
<string name="permission_bluetooth_scan_label">Scanare Bluetooth</string>
<string name="permission_bluetooth_scan_description">Permisiunea „Scanare Bluetooth” permite acestei aplicații să descopere și să primească date Bluetooth de la dispozitivele din apropiere, cum ar fi AirPod-urile dvs.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Această aplicație necesită permisiunea „Bluetooth” pentru a se conecta la dispozitive Bluetooth asociate.</string>
<string name="permission_access_fine_location_label">Permiteți locație exactă</string>
<string name="permission_access_fine_location_description">CAPod folosește permisiunea „locație exactă” pentru a primi date Bluetooth Energie Scăzută (BLE). Căștile dvs. folosesc tehnologia Bluetooth Energie Scăzută (BLE) pentru a-și transmite starea. Această aplicație NU va folosi date Bluetooth pentru a vă determina locația.</string>
<string name="permission_background_location_label">Permiteți acces la locație în fundal</string>
<string name="permission_background_location_description">CAPod folosește „accesul la locație în fundal” pentru a activa funcții precum „Afișare pop-up” și „Conectare Automată” în timp ce aplicația este închisă. Accesul la locație în fundal permite aplicației să primească date Bluetooth Energie Scăzută (BLE) în timp ce se află în fundal. Această aplicație NU va folosi date Bluetooth pentru a vă determina locația.</string>
<string name="permission_ignore_battery_optimizations_label">Dezactivați optimizări ale bateriei</string>
<string name="permission_ignore_battery_optimizations_description">Optimizările bateriei împiedică această aplicație să primească în mod fiabil date Bluetooth în timp ce aplicația este în fundal.</string>
<string name="permission_required_title">Este necesară următoarea permisiune:</string>
<string name="permission_system_alert_window_label">Fereastra de alertă de sistem</string>
<string name="permission_system_alert_window_description">Permiteți CAPod să apară peste alte aplicații pentru a face posibilă funcția „Afișați pop-up”.</string>
<string name="settings_scanner_mode_lowpower_label">Putere redusă</string>
<string name="settings_scanner_mode_balanced_label">Echilibrat</string>
<string name="settings_scanner_mode_lowlatency_label">Latență scăzută</string>
<string name="settings_monitor_mode_manual_label">Când aplicația este deschisă</string>
<string name="settings_monitor_mode_automatic_label">Când dispozitivul este conectat</string>
<string name="settings_monitor_mode_always_label">Mereu</string>
<string name="settings_reaction_autoconnect_whenseen_label">Când este detectat</string>
<string name="settings_reaction_autoconnect_caseopen_label">Carcasa este deschisă</string>
<string name="settings_reaction_autoconnect_inear_label">În ureche</string>
<string name="pods_dual_left_label">Casca stângă</string>
<string name="pods_dual_right_label">Casca dreaptă</string>
<string name="pods_case_label">Carcasă</string>
<string name="pods_case_status_open_label">Deschisă</string>
<string name="pods_case_status_closed_label">Închisă</string>
<string name="pods_connection_state_disconnected_label">Nu sunt conectate la un dispozitiv</string>
<string name="pods_connection_state_idle_label">Conectate la un dispozitiv, dar inactiv</string>
<string name="pods_connection_state_music_label">În modul muzică</string>
<string name="pods_connection_state_call_label">În modul apel</string>
<string name="pods_connection_state_ringing_label">Sună</string>
<string name="pods_connection_state_hanging_up_label">Închidere</string>
<string name="pods_connection_state_unknown_label">Starea conexiunii necunoscută</string>
<string name="pods_unknown_raw_data_label">Date neprelucrate</string>
<string name="pods_unknown_label">Dispozitiv necunoscut</string>
<string name="pods_unknown_contact_dev">Acesta este un dispozitiv necunoscut, dar folosește un format de mesaj similar. Să adăugăm suport pentru el, contactează-mă :)</string>
<string name="pods_none_label_short">Niciun dispozitiv</string>
<string name="pods_charging_label">Se încarcă</string>
<string name="pods_inear_label">În ureche</string>
<string name="pods_microphone_label">Microfon</string>
<string name="pods_yours">Ale tale</string>
<string name="headset_being_worn_label">Fiind purtate</string>
<string name="pods_case_unknown_state">Stare necunoscută</string>
<string name="last_seen_x">Vazute ultima dată: %s</string>
<string name="first_seen_x">Prima dată văzute: %s</string>
<string name="permission_post_notifications_label">Arată notificări</string>
<string name="permission_post_notifications_description">"Permiteți ca CAPod să afișeze notificări despre AirPod-urile dvs., de exemplu, starea lor actuală în timp ce sunt conectate."</string>
</resources>
@@ -58,6 +58,6 @@
<string name="pods_case_unknown_state">Неизвестное состояние</string>
<string name="last_seen_x">Последние: %s</string>
<string name="first_seen_x">Первые: %s</string>
<string name="permission_post_notifications_label">Уведомления о публикации</string>
<string name="permission_post_notifications_description">"Разрешить CAPod показывать уведомления о ваших AirPods, например текущий статус при подключении."</string>
<string name="permission_post_notifications_label">Показывать уведомления</string>
<string name="permission_post_notifications_description">"Разрешить CAPod показывать уведомления о Ваших AirPods, например текущий статус при подключении."</string>
</resources>
@@ -59,5 +59,5 @@
<string name="last_seen_x">Naposledy videné: %s</string>
<string name="first_seen_x">Prvýkrát videné: %s</string>
<string name="permission_post_notifications_label">Zobraziť oznámenia</string>
<string name="permission_post_notifications_description">"Povoľte CAPod, aby vám zobrazoval upozornenia týkajúce sa vašich slúchadiel AirPods, napr. ich aktuálny stav počas pripojenia."</string>
<string name="permission_post_notifications_description">"Povoľte CAPod, aby zobrazoval upozornenia týkajúce sa vašich slúchadiel AirPods, napr. ich aktuálny stav počas pripojenia."</string>
</resources>
+23 -23
View File
@@ -3,33 +3,33 @@
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">Yok</string>
<string name="general_value_not_available_label">Kapalı</string>
<string name="general_error_label">Hata</string>
<string name="general_grant_permission_action">İzin ver</string>
<string name="overview_nomaindevice_label">Birincil cihaz yok</string>
<string name="overview_nomaindevice_description">Tespit edilen tüm cihazların sizin olması muhtemel değildir. Cihazınızı açın ve bağlayın veya ayarları yapın.</string>
<string name="overview_bluetooth_disabled_label">Bluetooth devre dışı</string>
<string name="overview_bluetooth_disabled_description">Bluetooth devre dışı, lütfen etkinleştirin ;)</string>
<string name="overview_nomaindevice_label">Birincil aygıt yok</string>
<string name="overview_nomaindevice_description">Tespit edilen tüm aygıtların sizin olması muhtemel değildir. Aygıtınızı açın ve bağlayın veya ayarları yapın.</string>
<string name="overview_bluetooth_disabled_label">Bluetooth kapalı</string>
<string name="overview_bluetooth_disabled_description">Lütfen etkinleştirin ;)</string>
<string name="permission_bluetooth_connect_label">Bluetooth bağlantısı</string>
<string name="permission_bluetooth_connect_description">Bu uygulama, eşleştirilmiş cihazlarla etkileşime geçmek ve bağlantıları başlatmak için \"Bluetooth bağlantısı\" izni gerektirir.</string>
<string name="permission_bluetooth_connect_description">Bu uygulama, eşleştirilmiş aygıtlarla etkileşime geçmek ve bağlantıları başlatmak için \"Bluetooth bağlantısı\" izni gerektirir.</string>
<string name="permission_bluetooth_scan_label">Bluetooth taraması</string>
<string name="permission_bluetooth_scan_description">\"Bluetooth tarama\" izni, bu uygulamanın AirPod\'larınız gibi yakındaki cihazlardan Bluetooth verilerini keşfetmesine ve almasına olanak tanır.</string>
<string name="permission_bluetooth_scan_description">\"Bluetooth tarama\" izni, bu uygulamanın AirPod\'larınız gibi yakınınızdaki aygıtlardan Bluetooth verilerini keşfetmesine ve almasına olanak tanır.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Bu uygulama, eşleştirilmiş bluetooth cihazlarına bağlanmak için \"Bluetooth\" izni gerektirir.</string>
<string name="permission_bluetooth_description">Bu uygulama, eşleştirilmiş bluetooth aygıtlarına bağlanmak için \"Bluetooth\" izni gerektirir.</string>
<string name="permission_access_fine_location_label">Tam konuma erişim</string>
<string name="permission_access_fine_location_description">CAPod, Bluetooth Düşük Enerji verilerini almak için \"tam konum\" iznini kullanır. Kulaklığınız, durumlarını yayınlamak için Bluetooth Low Energy teknolojisini kullanır. Bu uygulama, konumunuzu belirlemek için Bluetooth verilerini KULLANMAZ.</string>
<string name="permission_access_fine_location_description">CAPod, Bluetooth Düşük Enerji verilerini almak için \"tam konum\" iznini kullanır. Kulaklığınız, durumlarını yayınlamak için Bluetooth Düşük Enerji teknolojisini kullanır. Bu uygulama, konumunuzu belirlemek için Bluetooth verilerini KULLANMAZ.</string>
<string name="permission_background_location_label">Arka planda konum erişimi</string>
<string name="permission_background_location_description">CAPod, uygulama kapalıyken \"Açılır pencereyi göster\" ve \"Otomatik Bağlantı\" gibi özellikleri etkinleştirmek için \"arka planda konum erişimi\"ni kullanır. Arka planda konum erişimi, uygulamanın arka plandayken Bluetooth Düşük Enerji verilerini almasına olanak tanır. Bu uygulama, konumunuzu belirlemek için Bluetooth verilerini KULLANMAZ.</string>
<string name="permission_ignore_battery_optimizations_label">Pil optimizasyonlarını devre dışı bırak</string>
<string name="permission_ignore_battery_optimizations_label">Pil optimizasyonunu devre dışı bırak</string>
<string name="permission_ignore_battery_optimizations_description">Pil optimizasyonları, uygulamanın arka planda çalışırken güvenilir bir şekilde Bluetooth verilerini almasını engeller.</string>
<string name="permission_required_title">Aşağıdaki izin gereklidir:</string>
<string name="permission_system_alert_window_label">Sistem Uyarı Penceresi</string>
<string name="permission_system_alert_window_description">\"Açılır Pencere Göster\" özelliğine izin vermek için CAPod\'un diğer uygulamaların üzerinde göstermesine izin vermelisiniz.</string>
<string name="permission_system_alert_window_description">\"Açılır pencereyi göster\" özelliğine izin vermek için lütfen CAPod uygulamasının diğer uygulamaların üzerinde gösterilmesine izin verin.</string>
<string name="settings_scanner_mode_lowpower_label">Düşük güç</string>
<string name="settings_scanner_mode_balanced_label">Dengeli</string>
<string name="settings_scanner_mode_lowlatency_label">Düşük gecikme süresi</string>
<string name="settings_scanner_mode_lowlatency_label">Düşük gecikme</string>
<string name="settings_monitor_mode_manual_label">Uygulama açıkken</string>
<string name="settings_monitor_mode_automatic_label">Cihaz bağlandığında</string>
<string name="settings_monitor_mode_automatic_label">Aygıt bağlandığında</string>
<string name="settings_monitor_mode_always_label">Her zaman</string>
<string name="settings_reaction_autoconnect_whenseen_label">Görüldüğünde</string>
<string name="settings_reaction_autoconnect_caseopen_label">Şarj kutusu açık</string>
@@ -39,25 +39,25 @@
<string name="pods_case_label">Şarj kutusu</string>
<string name="pods_case_status_open_label">Açık</string>
<string name="pods_case_status_closed_label">Kapalı</string>
<string name="pods_connection_state_disconnected_label">Bir cihaza bağlı değil</string>
<string name="pods_connection_state_idle_label">Bir cihaza bağlı, ancak boşta</string>
<string name="pods_connection_state_disconnected_label">Bir aygıta bağlı değil</string>
<string name="pods_connection_state_idle_label">Bir aygıta bağlı, ancak boşta</string>
<string name="pods_connection_state_music_label">Müzik modunda</string>
<string name="pods_connection_state_call_label">Arama modunda</string>
<string name="pods_connection_state_ringing_label">Çalıyor</string>
<string name="pods_connection_state_hanging_up_label">Kapatılıyor</string>
<string name="pods_connection_state_unknown_label">Bilinmeyen bağlantı durumu</string>
<string name="pods_unknown_raw_data_label">Ham veri</string>
<string name="pods_unknown_label">Tanımlanmamış cihaz</string>
<string name="pods_unknown_contact_dev">Bu bilinmeyen bir cihazdır, ancak benzer bir mesaj formatı kullanıyor. Bunun için destek ekleyelim, bana ulaşın :)</string>
<string name="pods_none_label_short">Cihaz yok</string>
<string name="pods_charging_label">Doluyor</string>
<string name="pods_unknown_label">Bilinmeyen aygıt</string>
<string name="pods_unknown_contact_dev">Bu bilinmeyen bir aygıt ama benzer mesaj formatı kullanıyor. Hadi buna destek ekleyelim, bana ulaşın :)</string>
<string name="pods_none_label_short">Aygıt yok</string>
<string name="pods_charging_label">Şarj oluyor</string>
<string name="pods_inear_label">Kulakta</string>
<string name="pods_microphone_label">Mikrofon</string>
<string name="pods_yours">Sizin</string>
<string name="headset_being_worn_label">Giyilmek</string>
<string name="pods_yours">Sizinki</string>
<string name="headset_being_worn_label">Takılıyken</string>
<string name="pods_case_unknown_state">Bilinmeyen durum</string>
<string name="last_seen_x">Son görülme: %s</string>
<string name="first_seen_x">İlk görülme: %s</string>
<string name="permission_post_notifications_label">Gönderi bildirimleri</string>
<string name="permission_post_notifications_description">"CAPod'un size AirPod'larınız hakkında bildirimler göstermesine izin verin, ör. bağlıyken geçerli durumları."</string>
<string name="permission_post_notifications_label">Bildirimleri göster</string>
<string name="permission_post_notifications_description">"CAPod'un AirPods'larınız hakkında bildirimler göstermesine izin vermelisiniz, ör. cihaza bağlıyken mevcut durumları."</string>
</resources>
+23 -2
View File
@@ -1,23 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="general_value_not_available_label">Н</string>
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">Н</string>
<string name="general_error_label">Помилка</string>
<string name="general_grant_permission_action">Надати дозвіл</string>
<string name="overview_nomaindevice_label">Відсутній первинний пристрій</string>
<string name="overview_nomaindevice_description">Всі знайдені пристрої навряд чи ваші. Увімкніть та підʼєднайте ваш пристрій або налаштуйте параметри.</string>
<string name="overview_bluetooth_disabled_label">Bluetooth вимкнено</string>
<string name="overview_bluetooth_disabled_description">Bluetooth вимкнено, увімкніть його ;)</string>
<string name="permission_bluetooth_connect_label">Bluetooth зʼєднання</string>
<string name="permission_bluetooth_connect_description">Цей додаток потребує дозвіл \"Bluetooth зʼєднання\" щоб взаємодіяти зі сполученими пристроями та ініціювати нові зʼєднання.</string>
<string name="permission_bluetooth_scan_label">Сканування Bluetooth</string>
<string name="permission_bluetooth_scan_description">Дозвіл \"Сканування Bluetooth\" дозволяє цьому додатку виявляти та отримувати Bluetooth дані з пристроїв поблизу, таких як ваші AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Цей додаток потребує дозвіл \"Bluetooth\" для зʼєднання зі сполученими Bluetooth пристроями.</string>
<string name="permission_access_fine_location_label">Доступ до точного місцезнаходження</string>
<string name="permission_access_fine_location_description">CAPod використовує дозвіл \"Точне місцезнаходження\" щоб отримувати дані Bluetooth Low Energy. Ваші навушники використовують технологію Bluetooth Low Energy technology щоб транслювати свій статус. Цей додаток НЕ використовує Bluetooth дані щоб визначати ваше місцеперебування.</string>
<string name="permission_background_location_label">Доступ до місцезнаходження у тлі</string>
<string name="permission_background_location_description">CAPod використовує фоновий доступ до геоданих для роботи таких функцій як \"Показувати спливаюче вікно\" та \"Автопідʼєднання\" коли додаток закритий. Фоновий доступ до геоданих дозволяє додатку отримувати дані Bluetooth Low Energy коли він працює у фоновому режимі. Цей додаток НЕ використовує Bluetooth дані щоб визначати ваше місцеперебування.</string>
<string name="permission_ignore_battery_optimizations_label">Вимкнути оптимізацію батареї</string>
<string name="permission_ignore_battery_optimizations_description">Оптимізація батареї перешкоджає цьому пристрою надійно отримувати дані Bluetooth під час перебування програми у тлі.</string>
<string name="permission_required_title">Необхідні наступні дозволи:</string>
<string name="permission_system_alert_window_label">Вікно системного повідомлення</string>
<string name="settings_scanner_mode_lowpower_label">Низький заряд</string>
<string name="permission_system_alert_window_description">Надайте дозвіл CAPod на показ поверх інших додатків для роботи функції \"Показувати спливаюче вікно\".</string>
<string name="settings_scanner_mode_lowpower_label">Економія енергії</string>
<string name="settings_scanner_mode_balanced_label">Збалансовано</string>
<string name="settings_scanner_mode_lowlatency_label">Низька затримка</string>
<string name="settings_monitor_mode_manual_label">Коли програму відкрито</string>
<string name="settings_monitor_mode_automatic_label">При під\'єднаному пристрої</string>
<string name="settings_monitor_mode_always_label">Завжди</string>
<string name="settings_reaction_autoconnect_whenseen_label">При виявленні</string>
<string name="settings_reaction_autoconnect_caseopen_label">Футляр відчинено</string>
<string name="settings_reaction_autoconnect_inear_label">У вусі</string>
<string name="pods_dual_left_label">Лівий навушник</string>
@@ -26,9 +40,13 @@
<string name="pods_case_status_open_label">Відкрито</string>
<string name="pods_case_status_closed_label">Закрито</string>
<string name="pods_connection_state_disconnected_label">Не під\'єднано до пристрою</string>
<string name="pods_connection_state_idle_label">Підʼєднано до пристрою, в режимі очікування</string>
<string name="pods_connection_state_music_label">У режимі музики</string>
<string name="pods_connection_state_call_label">У режимі виклику</string>
<string name="pods_connection_state_ringing_label">Дзвінок</string>
<string name="pods_connection_state_hanging_up_label">Кінець дзвінка</string>
<string name="pods_connection_state_unknown_label">Невідомий стан з\'єднання</string>
<string name="pods_unknown_raw_data_label">Сирі дані</string>
<string name="pods_unknown_label">Невідомий пристрій</string>
<string name="pods_unknown_contact_dev">Це невідомий пристрій, проте він використовує подібний формат повідомлень. Додаймо його підтримку, надішліть мені лист :)</string>
<string name="pods_none_label_short">Немає пристрою</string>
@@ -36,7 +54,10 @@
<string name="pods_inear_label">У вусі</string>
<string name="pods_microphone_label">Мікрофон</string>
<string name="pods_yours">Ваш</string>
<string name="headset_being_worn_label">Одягнені</string>
<string name="pods_case_unknown_state">Невідомий стан</string>
<string name="last_seen_x">Востаннє був: %s</string>
<string name="first_seen_x">Уперше був: %s</string>
<string name="permission_post_notifications_label">Показувати сповіщення</string>
<string name="permission_post_notifications_description">"Дозволити CAPod показ сповіщень щодо ваших AirPods, наприклад, їх поточний стан при підключенні."</string>
</resources>
@@ -53,4 +53,6 @@
<string name="pods_case_unknown_state">Trạng thái không xác định</string>
<string name="last_seen_x">Nhìn thấy lần cuối: %s</string>
<string name="first_seen_x">Nhìn thấy lần đầu: %s</string>
<string name="permission_post_notifications_label">Hiển thị thông báo</string>
<string name="permission_post_notifications_description">"Cho phép CAPod hiển thị thông báo về AirPods của bạn, ví dụ: tình trạng hiện tại của họ trong khi kết nối."</string>
</resources>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">不可用</string>
<string name="general_error_label">错误</string>
<string name="general_grant_permission_action">授予权限</string>
@@ -55,4 +58,6 @@
<string name="pods_case_unknown_state">未知状态</string>
<string name="last_seen_x">上次出现:%s</string>
<string name="first_seen_x">首次出现:%s</string>
<string name="permission_post_notifications_label">显示通知</string>
<string name="permission_post_notifications_description">"允许 CAPod 显示有关您 AirPods 的通知,例如其连接时的当前状态"</string>
</resources>
@@ -58,6 +58,6 @@
<string name="pods_case_unknown_state">未知狀態</string>
<string name="last_seen_x">最後連線:%s</string>
<string name="first_seen_x">首次連線:%s</string>
<string name="permission_post_notifications_label">張貼通知</string>
<string name="permission_post_notifications_description">"允許 CAPod 為您顯示關於您 AirPods 的通知,例如在連線時顯示它們的目前狀態。"</string>
<string name="permission_post_notifications_label">顯示通知</string>
<string name="permission_post_notifications_description">"允許 CAPod 顯示關於您 AirPods 的通知,例如在連線時顯示它們的目前狀態。"</string>
</resources>
@@ -58,6 +58,6 @@
<string name="pods_case_unknown_state">未知狀態</string>
<string name="last_seen_x">最後連線:%s</string>
<string name="first_seen_x">首次連線:%s</string>
<string name="permission_post_notifications_label">張貼通知</string>
<string name="permission_post_notifications_description">"允許 CAPod 為您顯示關於您 AirPods 的通知,例如在連線時顯示它們的目前狀態。"</string>
<string name="permission_post_notifications_label">顯示通知</string>
<string name="permission_post_notifications_description">"允許 CAPod 顯示關於您 AirPods 的通知,例如在連線時顯示它們的目前狀態。"</string>
</resources>
@@ -61,6 +61,7 @@
<string name="pods_microphone_label">Microphone</string>
<string name="pods_yours">Yours</string>
<string name="headset_being_worn_label">Being worn</string>
<string name="headset_not_being_worn_label">Not being worn</string>
<string name="pods_case_unknown_state">Unknown state</string>
<string name="last_seen_x">Last seen: %s</string>
@@ -3,80 +3,69 @@ package eu.darken.capod.common.flow
import eu.darken.capod.common.collections.mutate
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.instanceOf
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.*
import kotlinx.coroutines.test.advanceUntilIdle
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.coroutine.runTest2
import testhelpers.flow.test
import java.io.IOException
import java.lang.Thread.sleep
import kotlin.concurrent.thread
import kotlin.coroutines.EmptyCoroutineContext
class DynamicStateFlowTest : BaseTest() {
// Without an init value, there isn't a way to keep using the flow
@Test
fun `exceptions on initialization are rethrown`() {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val hotData = eu.darken.capod.common.flow.DynamicStateFlow<String>(
fun `exceptions on initialization are rethrown`() = runTest2(expectedError = IOException::class) {
val testScope = this
val hotData = DynamicStateFlow<String>(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = Dispatchers.Unconfined,
startValueProvider = { throw IOException() }
)
runBlocking {
withTimeoutOrNull(500) {
// This blocking scope gets the init exception as the first caller
hotData.flow.firstOrNull()
} shouldBe null
}
testScope.advanceUntilIdle()
testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class)
// This blocking scope gets the init exception as the first caller
hotData.flow.firstOrNull()
}
@Test
fun `subscription doesn't end when no subscriber is collecting, mode Lazily`() {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
fun `subscription doesn't end when no subscriber is collecting, mode Lazily`() = runTest2(autoCancel = true) {
val testScope = this
val valueProvider = mockk<suspend CoroutineScope.() -> String>()
coEvery { valueProvider.invoke(any()) } returns "Test"
val hotData = eu.darken.capod.common.flow.DynamicStateFlow(
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = Dispatchers.Unconfined,
startValueProvider = valueProvider,
)
testScope.apply {
runTest2(autoCancel = true) {
hotData.flow.first() shouldBe "Test"
hotData.flow.first() shouldBe "Test"
}
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
hotData.flow.first() shouldBe "Test"
hotData.flow.first() shouldBe "Test"
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
@Test
fun `value updates`() {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
fun `value updates`() = runTest2(autoCancel = true) {
val testScope = this
val valueProvider = mockk<suspend CoroutineScope.() -> Long>()
coEvery { valueProvider.invoke(any()) } returns 1
val hotData = eu.darken.capod.common.flow.DynamicStateFlow(
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = valueProvider,
@@ -88,7 +77,6 @@ class DynamicStateFlowTest : BaseTest() {
(1..16).forEach { _ ->
thread {
(1..200).forEach { _ ->
sleep(10)
hotData.updateAsync(
onUpdate = { this + 1L },
onError = { throw it }
@@ -97,12 +85,16 @@ class DynamicStateFlowTest : BaseTest() {
}
}
runBlocking {
testCollector.await { list, _ -> list.size == 3201 }
testCollector.latestValues shouldBe (1..3201).toList()
}
advanceUntilIdle()
testCollector.await { list, _ -> list.size == 3201 }
testCollector.latestValues shouldBe (1..3201).toList()
advanceUntilIdle()
coVerify(exactly = 1) { valueProvider.invoke(any()) }
testCollector.cancelAndJoin()
}
data class TestData(
@@ -110,14 +102,13 @@ class DynamicStateFlowTest : BaseTest() {
)
@Test
fun `check multi threading value updates with more complex data`() {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val valueProvider =
mockk<suspend CoroutineScope.() -> Map<String, eu.darken.capod.common.flow.DynamicStateFlowTest.TestData>>()
coEvery { valueProvider.invoke(any()) } returns mapOf("data" to eu.darken.capod.common.flow.DynamicStateFlowTest.TestData())
fun `check multi threading value updates with more complex data`() = runTest2(autoCancel = true) {
val testScope = this
val hotData = eu.darken.capod.common.flow.DynamicStateFlow(
val valueProvider = mockk<suspend CoroutineScope.() -> Map<String, TestData>>()
coEvery { valueProvider.invoke(any()) } returns mapOf("data" to TestData())
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = valueProvider,
@@ -140,20 +131,23 @@ class DynamicStateFlowTest : BaseTest() {
}
}
runBlocking {
testCollector.await { list, _ -> list.size == 4001 }
testCollector.latestValues.map { it.values.single().number } shouldBe (1L..4001L).toList()
}
advanceUntilIdle()
testCollector.await { list, _ -> list.size == 4001 }
testCollector.latestValues.map { it.values.single().number } shouldBe (1L..4001L).toList()
advanceUntilIdle()
coVerify(exactly = 1) { valueProvider.invoke(any()) }
testCollector.cancelAndJoin()
}
@Test
fun `only emit new values if they actually changed updates`() {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
fun `only emit new values if they actually changed updates`() = runTest2(autoCancel = true) {
val testScope = this
val hotData = eu.darken.capod.common.flow.DynamicStateFlow(
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = { "1" },
@@ -167,26 +161,28 @@ class DynamicStateFlowTest : BaseTest() {
hotData.updateAsync { "2" }
hotData.updateAsync { "1" }
runBlocking {
testCollector.await { list, _ -> list.size == 3 }
testCollector.latestValues shouldBe listOf("1", "2", "1")
}
advanceUntilIdle()
testCollector.await { list, _ -> list.size == 3 }
testCollector.latestValues shouldBe listOf("1", "2", "1")
}
@Test
fun `multiple subscribers share the flow`() = runTest2(autoCancel = true) {
val testScope = this
val valueProvider = mockk<suspend CoroutineScope.() -> String>()
coEvery { valueProvider.invoke(any()) } returns "Test"
val hotData = eu.darken.capod.common.flow.DynamicStateFlow(
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = this,
parentScope = testScope,
startValueProvider = valueProvider,
)
val sub1 = hotData.flow.test(tag = "sub1", scope = this)
val sub2 = hotData.flow.test(tag = "sub2", scope = this)
val sub3 = hotData.flow.test(tag = "sub3", scope = this)
val sub1 = hotData.flow.test(tag = "sub1", scope = testScope)
val sub2 = hotData.flow.test(tag = "sub2", scope = testScope)
val sub3 = hotData.flow.test(tag = "sub3", scope = testScope)
hotData.updateAsync { "A" }
hotData.updateAsync { "B" }
@@ -207,17 +203,19 @@ class DynamicStateFlowTest : BaseTest() {
@Test
fun `value is persisted between unsubscribes`() = runTest2(autoCancel = true) {
val testScope = this
val valueProvider = mockk<suspend CoroutineScope.() -> Long>()
coEvery { valueProvider.invoke(any()) } returns 1
val hotData = eu.darken.capod.common.flow.DynamicStateFlow(
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = this,
parentScope = testScope,
coroutineContext = this.coroutineContext,
startValueProvider = valueProvider,
)
val testCollector1 = hotData.flow.test(tag = "collector1", scope = this)
val testCollector1 = hotData.flow.test(tag = "collector1", scope = testScope)
testCollector1.silent = false
(1..10).forEach { _ ->
@@ -233,7 +231,7 @@ class DynamicStateFlowTest : BaseTest() {
testCollector1.cancelAndJoin()
val testCollector2 = hotData.flow.test(tag = "collector2", scope = this)
val testCollector2 = hotData.flow.test(tag = "collector2", scope = testScope)
testCollector2.silent = false
advanceUntilIdle()
@@ -246,10 +244,10 @@ class DynamicStateFlowTest : BaseTest() {
}
@Test
fun `blocking update is actually blocking`() = runBlocking {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val hotData = eu.darken.capod.common.flow.DynamicStateFlow(
fun `blocking update is actually blocking`() = runTest2(autoCancel = true) {
val testScope = this
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = testScope.coroutineContext,
@@ -270,6 +268,8 @@ class DynamicStateFlowTest : BaseTest() {
hotData.updateBlocking { this - 3 } shouldBe 0
advanceUntilIdle()
testCollector.await { _, i -> i == 3 }
testCollector.latestValues shouldBe listOf(2, 3, 0)
@@ -277,10 +277,10 @@ class DynamicStateFlowTest : BaseTest() {
}
@Test
fun `blocking update rethrows error`() = runBlocking {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val hotData = eu.darken.capod.common.flow.DynamicStateFlow(
fun `blocking update rethrows error`() = runTest2(autoCancel = true) {
val testScope = this
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = testScope.coroutineContext,
@@ -300,16 +300,17 @@ class DynamicStateFlowTest : BaseTest() {
hotData.flow.first() shouldBe 2
hotData.updateBlocking { 3 } shouldBe 3
hotData.flow.first() shouldBe 3
testScope.uncaughtExceptions.singleOrNull() shouldBe null
advanceUntilIdle()
hotData.flow.first() shouldBe 3
testCollector.cancelAndJoin()
}
@Test
fun `async updates error handler`() = runTest2(expectedError = IOException::class) {
val hotData = eu.darken.capod.common.flow.DynamicStateFlow(
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = this,
startValueProvider = { 1 },
@@ -321,14 +322,15 @@ class DynamicStateFlowTest : BaseTest() {
hotData.updateAsync { throw IOException("Surprise") }
advanceUntilIdle()
testCollector.cancelAndJoin()
}
@Test
fun `async updates rethrow errors on HotDataFlow scope if no error handler is set`() = runBlocking {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
fun `async updates rethrow errors on HotDataFlow scope if no error handler is set`() = runTest2(autoCancel = true) {
val testScope = this
val hotData = eu.darken.capod.common.flow.DynamicStateFlow(
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = { 1 },
@@ -346,31 +348,29 @@ class DynamicStateFlowTest : BaseTest() {
testScope.advanceUntilIdle()
thrownError!!.shouldBeInstanceOf<IOException>()
testScope.uncaughtExceptions.singleOrNull() shouldBe null
testCollector.cancelAndJoin()
}
@Test
fun `clean up function is called when parent scope is cancelled`() = runTest {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
fun `clean up function is called when parent scope is cancelled`() {
var onReleaseValue: String? = null
val hotData = eu.darken.capod.common.flow.DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = Dispatchers.Unconfined,
startValueProvider = { "Test" },
onRelease = {
onReleaseValue = it
}
)
runTest2(autoCancel = true) {
val testScope = this
hotData.flow.first() shouldBe "Test"
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = Dispatchers.Unconfined,
startValueProvider = { "Test" },
onRelease = {
onReleaseValue = it
}
)
testScope.cancel()
hotData.flow.first() shouldBe "Test"
}
onReleaseValue shouldBe "Test"
}
@@ -0,0 +1,72 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsPro2UsbcTest : BaseAirPodsTest() {
/**
* https://github.com/d4rken-org/capod/issues/164
*/
@Test
fun `AirPods Pro 2 with USB-C - via #164`() = runTest {
create<AirPodsPro2Usbc>("07 19 01 24 20 0B 99 8F 11 00 04 BD A7 3B FF 2D 8A 3C AF 9B 1A 7C 74 B7 A9 D1 C3") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x2420.toUShort()
rawStatus shouldBe 0x0B.toUByte()
rawPodsBattery shouldBe 0x99.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x11.toUByte()
rawDeviceColor shouldBe 0x0.toUByte()
rawSuffix shouldBe 0x04.toUByte()
isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe true
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.9f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO2_USBC
}
}
@Test
fun `AirPods Pro 2 with USB-C - via #164 - in case`() = runTest {
create<AirPodsPro2Usbc>("07 19 01 24 20 53 AA 98 32 00 05 49 0A B8 BF 8E 29 D8 70 12 0D A7 0C CE 77 56 00") {
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe false
batteryLeftPodPercent shouldBe 1f
batteryRightPodPercent shouldBe 1f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.8f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO2_USBC
}
}
}
@@ -40,4 +40,33 @@ class BeatsFitProTest : BaseAirPodsTest() {
model shouldBe PodDevice.Model.BEATS_FIT_PRO
}
}
@Test
fun `extra rl test case`() = runTest {
create<BeatsFitPro>("07 19 01 12 20 04 FA 92 54 11 24 CE B1 DF 9D 8D F5 E3 37 60 B1 23 8B 90 3B 63 3F") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1220.toUShort()
rawStatus shouldBe 0x04.toUByte()
rawPodsBattery shouldBe 0xFA.toUByte()
rawFlags shouldBe 0x9.toUShort()
rawCaseBattery shouldBe 0x2.toUShort()
rawCaseLidState shouldBe 0x54.toUByte()
rawDeviceColor shouldBe 0x11.toUByte()
rawSuffix shouldBe 0x24.toUByte()
isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true
batteryLeftPodPercent shouldBe null
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.2f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.UNKNOWN.name
model shouldBe PodDevice.Model.BEATS_FIT_PRO
}
}
}
@@ -0,0 +1,75 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class PowerBeats4Test : BaseAirPodsTest() {
@Test
fun `playing music`() = runTest {
create<PowerBeats4>("07 19 01 0D 20 00 02 80 01 00 05 07 C5 E1 9C BD 9A 05 0E AE 9E 56 53 2F F9 75 4A") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0D20.toUShort()
rawStatus shouldBe 0x0.toUByte()
rawPodsBattery shouldBe 0x02.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x05.toUByte()
batteryHeadsetPercent shouldBe 0.2f
model shouldBe PodDevice.Model.POWERBEATS_4
state shouldBe HasStateDetectionAirPods.ConnectionState.MUSIC
}
}
@Test
fun `extra test case`() = runTest {
create<PowerBeats4>("07 19 01 0D 20 00 02 80 02 00 04 80 90 60 77 32 C2 0C 75 7A 3D 7E D7 1C 0E B8 43") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0D20.toUShort()
rawStatus shouldBe 0x0.toUByte()
rawPodsBattery shouldBe 0x02.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x02.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x04.toUByte()
batteryHeadsetPercent shouldBe 0.2f
model shouldBe PodDevice.Model.POWERBEATS_4
state shouldBe HasStateDetectionAirPods.ConnectionState.IDLE
}
}
@Test
fun `disconnected state`() = runTest {
create<PowerBeats4>("07 19 01 0D 20 00 02 80 01 00 00 25 DF 66 40 AF 44 7B 77 95 8F D1 92 50 26 11 74") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0D20.toUShort()
rawStatus shouldBe 0x0.toUByte()
rawPodsBattery shouldBe 0x02.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.2f
model shouldBe PodDevice.Model.POWERBEATS_4
state shouldBe HasStateDetectionAirPods.ConnectionState.DISCONNECTED
}
}
}
@@ -1,24 +0,0 @@
package testhelpers.coroutine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext
@ExperimentalCoroutinesApi
class CoroutinesTestExtension(
private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : BeforeEachCallback, AfterEachCallback,
TestCoroutineScope by createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + dispatcher) {
override fun beforeEach(context: ExtensionContext?) {
Dispatchers.setMain(dispatcher)
}
override fun afterEach(context: ExtensionContext?) {
cleanupTestCoroutines()
Dispatchers.resetMain()
}
}
+2 -6
View File
@@ -45,12 +45,9 @@ android {
create("gplay") {
dimension = "version"
signingConfig = signingConfigs["releaseGplay"]
extra["useBugsnag"] = true
}
}
setupBugsnagPlugin()
buildTypes {
val customProguardRules = fileTree(File("../proguard")) {
include("*.pro")
@@ -133,13 +130,12 @@ dependencies {
addBaseAndroid()
addBaseAndroidUi()
implementation("androidx.core:core-splashscreen:1.0.1")
addNavigation()
addTesting()
implementation("androidx.wear:wear:1.2.0")
implementation("androidx.wear.tiles:tiles-material:1.1.0")
"gplayImplementation"("com.bugsnag:bugsnag-android:5.9.2")
"gplayImplementation"("com.getkeepsafe.relinker:relinker:1.4.3")
}
+3 -2
View File
@@ -4,7 +4,8 @@
package="eu.darken.capod">
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.type.watch" />
<application
@@ -41,4 +42,4 @@
tools:node="remove" />
</application>
</manifest>
</manifest>
@@ -0,0 +1,18 @@
package eu.darken.capod.wear.core
import android.content.Context
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
data class UserTime(
val time: Instant = Instant.now(),
) {
fun toFormatted(context: Context): String = DateTimeFormatter
.ofLocalizedTime(FormatStyle.SHORT)
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault())
.format(time)
}
@@ -17,6 +17,7 @@ import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.wear.core.UserTime
import eu.darken.capod.wear.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.wear.ui.overview.cards.MissingMainDeviceVH
import eu.darken.capod.wear.ui.overview.cards.PermissionCardVH
@@ -86,14 +87,20 @@ class OverviewFragmentVM @Inject constructor(
device = podToShow,
showDebug = isDebugMode,
isMainPod = true,
userTime = UserTime(),
)
is SinglePodDevice -> SinglePodsCardVH.Item(
now = now,
device = podToShow,
showDebug = isDebugMode,
isMainPod = true,
userTime = UserTime(),
)
else -> MissingMainDeviceVH.Item(
userTime = UserTime(),
)
else -> MissingMainDeviceVH.Item
}
items.add(pod)
@@ -5,6 +5,7 @@ import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.databinding.OverviewNomaindeviceItemBinding
import eu.darken.capod.wear.core.UserTime
import eu.darken.capod.wear.ui.overview.OverviewAdapter
class MissingMainDeviceVH(parent: ViewGroup) :
@@ -21,11 +22,14 @@ class MissingMainDeviceVH(parent: ViewGroup) :
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
userTime.text = item.userTime.toFormatted(context)
}
object Item : OverviewAdapter.Item {
override val stableId: Long = Item::class.hashCode().toLong()
data class Item(
val userTime: UserTime,
) : OverviewAdapter.Item {
override val stableId: Long = this.hashCode().toLong()
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
get() = { old, new -> if (new::class.isInstance(old)) new else null }
@@ -9,6 +9,7 @@ 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.DualApplePods
import eu.darken.capod.wear.core.UserTime
import java.time.Instant
class DualPodsCardVH(parent: ViewGroup) :
@@ -20,7 +21,10 @@ class DualPodsCardVH(parent: ViewGroup) :
override val viewBinding = lazy { OverviewPodsDualItemBinding.bind(itemView) }
override val onBindData = binding(payload = true) { item: Item ->
userTime.text = item.userTime.toFormatted(context)
val device = item.device
name.apply {
val sb = StringBuilder(device.getLabel(context))
if (device is HasPodStyle && item.showDebug) {
@@ -127,5 +131,6 @@ class DualPodsCardVH(parent: ViewGroup) :
override val device: DualPodDevice,
override val showDebug: Boolean,
override val isMainPod: Boolean,
val userTime: UserTime,
) : PodDeviceVH.Item
}
@@ -8,6 +8,7 @@ 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 eu.darken.capod.wear.core.UserTime
import java.time.Instant
class SinglePodsCardVH(parent: ViewGroup) :
@@ -19,6 +20,8 @@ class SinglePodsCardVH(parent: ViewGroup) :
override val viewBinding = lazy { OverviewPodsSingleItemBinding.bind(itemView) }
override val onBindData = binding(payload = true) { item: Item ->
userTime.text = item.userTime.toFormatted(context)
val device = item.device
name.apply {
@@ -75,5 +78,6 @@ class SinglePodsCardVH(parent: ViewGroup) :
override val device: SinglePodDevice,
override val showDebug: Boolean,
override val isMainPod: Boolean,
val userTime: UserTime,
) : PodDeviceVH.Item
}
@@ -1,44 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M400.08,358.82l-220.33,59a58.52,58.52 0,0 1,-71.68 -41.38l-5,-18.53a171.22,171.22 0,0 1,24.44 -141.76l-23.43,-23.5a25,25 0,0 1,0 -35.35l0.82,-0.82a24.87,24.87 0,0 1,35.24 0l22.27,22.34a170.85,170.85 0,0 1,62 -31.09l0.48,-0.13a172.54,172.54 0,0 1,71.4 -3.89l8.63,-31.85a25.14,25.14 0,0 1,30.7 -17.66l1.13,0.29A24.8,24.8 0,0 1,354.42 125l-9.23,34.07a172.66,172.66 0,0 1,91.7 111l4.58,17.08A58.54,58.54 0,0 1,400.08 358.82Z"
android:fillColor="#41eb8e"
android:fillType="evenOdd" />
<path
android:pathData="M279.56,314.16c-13.7,-8 -31.7,-7.91 -45.66,-0.68L212,351.37c0.68,15.16 9.41,30.14 24.29,38.79a49.92,49.92 0,0 0,14.87 5.76l-20.81,36.44a8.28,8.28 0,0 0,3 11.33l12.8,7.44a8.26,8.26 0,0 0,11.31 -3l41.73,-72.75h0C310.82,354.32 306.77,330 279.56,314.16ZM247.67,347.24a6,6 0,1 1,-2.16 -8.26A6,6 0,0 1,247.67 347.24Z"
android:fillColor="#fff"
android:fillType="evenOdd" />
<path
android:pathData="M300.66,214.18a27.95,27.95 0,1 1,-19.8 34.24A28,28 0,0 1,300.66 214.18Z"
android:fillColor="#26845c"
android:fillType="evenOdd" />
<path
android:pathData="M194.53,242.62a27.95,27.95 0,1 1,-19.8 34.24A28,28 0,0 1,194.53 242.62Z"
android:fillColor="#26845c"
android:fillType="evenOdd" />
<path
android:pathData="M149.79,318.88l-36.3,-21.21a50.22,50.22 0,0 0,10 -12.43c8.56,-14.94 8.61,-32.3 1.59,-45.79l-38,-21.9c-15.72,0.7 -31.35,9.65 -39.22,23.41 -15.64,27.32 -6.94,50.45 13.74,62.85h0l72.64,42.25a8.26,8.26 0,0 0,11.31 -3l7.36,-12.85A8.31,8.31 0,0 0,149.79 318.88ZM100.3,251.53A6.06,6.06 0,1 1,98 243.28,6.07 6.07,0 0,1 100.3,251.53Z"
android:fillColor="#fff"
android:fillType="evenOdd" />
<path
android:pathData="M187.92,204.08a29.52,29.52 0,0 1,-38.6 -15.52A29.15,29.15 0,0 1,165 150.28a30.38,30.38 0,0 1,4.68 -1.52l-16.36,-38.48L148.6,99.07l-4.77,-11.21 40,-16.75 9.54,22.42L176,100.79l27.58,64.87 0.14,0.34a28.56,28.56 0,0 1,1.24 3.65A29.16,29.16 0,0 1,187.92 204.08Z"
android:fillColor="#ffda3e"
android:fillType="evenOdd" />
<path
android:pathData="M259.45,46l-0.77,13.75 -10.63,-0.57L245.84,99l-0.13,2.29h-0.16A16.6,16.6 0,1 1,232.87 82l1.32,-23.6h0l0.39,-6.87 0.38,-6.88Z"
android:fillColor="#ffcf3b"
android:fillType="evenOdd" />
<path
android:pathData="M205.22,169.54l-0.26,0.11a28.56,28.56 0,0 0,-1.24 -3.65Z"
android:fillColor="#e7b720"
android:fillType="evenOdd" />
<path
android:pathData="M95.05,247.69m-7.01,0a7.01,7.01 0,1 1,14.02 0a7.01,7.01 0,1 1,-14.02 0"
android:fillColor="#231f20" />
<path
android:pathData="M242.42,343.4m-7.01,0a7.01,7.01 0,1 1,14.02 0a7.01,7.01 0,1 1,-14.02 0"
android:fillColor="#231f20" />
</vector>
@@ -1,42 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="m256,13c134.21,0 243,108.79 243,243c0,134.21 -108.79,243 -243,243c-134.21,0 -243,-108.79 -243,-243c0,-134.21 108.79,-243 243,-243z"
android:fillColor="#3f7aff" />
<path
android:pathData="m404.3,630.55l-93.18,24.97c-59.46,15.93 -120.58,-19.35 -136.51,-78.82l-18.52,-69.12c-6.81,-25.42 8.28,-51.56 33.7,-58.37l216.44,-58c25.42,-6.81 51.56,8.28 58.37,33.7l18.52,69.12c15.93,59.46 -19.35,120.58 -78.82,136.51zM255.69,692.33l-1.04,-0.28c-12.36,-3.27 -19.67,-15.89 -16.33,-28.19l13.54,-49.96c3.33,-12.3 16.05,-19.62 28.41,-16.35l1.04,0.28c12.36,3.27 19.67,15.89 16.33,28.19l-13.54,49.96c-3.33,12.3 -16.05,19.62 -28.41,16.35zM436.6,634.7l-36.62,-36.73c-9.01,-9.04 -9.03,-23.69 -0.04,-32.71l0.76,-0.76c9,-9.02 23.6,-9.01 32.61,0.04l36.62,36.73c9.01,9.04 9.03,23.69 0.04,32.71l-0.76,0.76c-9,9.02 -23.6,9.01 -32.61,-0.04z"
android:fillColor="#41eb8e" />
<path
android:pathData="m229.33,167.8c88.82,-23.8 180.11,28.91 203.91,117.73l1.04,3.87c10.01,37.34 -12.15,75.73 -49.5,85.73l-186.4,49.95c-37.34,10.01 -75.73,-12.15 -85.73,-49.5l-1.04,-3.87c-23.8,-88.82 28.91,-180.11 117.73,-203.91zM335.69,116.4l1.09,0.29c12.88,3.41 20.51,16.57 17.03,29.39l-14.12,52.08c-3.48,12.82 -16.74,20.45 -29.62,17.04l-1.09,-0.29c-12.88,-3.41 -20.51,-16.57 -17.03,-29.39l14.12,-52.08c3.48,-12.82 16.74,-20.45 29.62,-17.04zM147.08,176.49l38.17,38.29c9.4,9.43 9.41,24.7 0.04,34.1l-0.79,0.79c-9.38,9.41 -24.6,9.39 -34,-0.04l-38.17,-38.29c-9.4,-9.43 -9.41,-24.7 -0.04,-34.1l0.79,-0.79c9.38,-9.41 24.6,-9.39 34,0.04z"
android:fillColor="#41eb8e" />
<path
android:pathData="m171.1,170.45c14.47,-6.06 31.14,0.64 37.24,14.97c6.09,14.33 -0.7,30.87 -15.17,36.93c-14.47,6.06 -31.14,-0.64 -37.24,-14.97c-6.09,-14.33 0.7,-30.87 15.17,-36.93zM155.24,121.04l21.83,-9.15l32.8,77.13l-21.83,9.15l-32.8,-77.13zM159.84,131.85l-9.2,-21.63l38.57,-16.16l9.2,21.63l-38.57,16.16zM233.88,104.24c8.87,0.47 15.66,7.98 15.17,16.76c-0.49,8.79 -8.07,15.53 -16.94,15.06c-8.87,-0.47 -15.66,-7.98 -15.17,-16.76c0.49,-8.79 8.07,-15.53 16.94,-15.06zM238.18,75.21l13.38,0.71l-2.63,47.3l-13.38,-0.71l2.63,-47.3zM237.81,81.84l0.74,-13.26l23.64,1.25l-0.74,13.26l-23.64,-1.25z"
android:fillColor="#ffcf3b" />
<path
android:pathData="m199.55,259.53c14.4,-3.86 29.2,4.67 33.06,19.06c3.85,14.38 -4.7,29.17 -19.1,33.03c-14.4,3.86 -29.2,-4.67 -33.06,-19.06c-3.85,-14.38 4.7,-29.17 19.1,-33.03z"
android:strokeAlpha="0.8"
android:fillColor="#1f6a50"
android:fillAlpha="0.8" />
<path
android:pathData="m301.94,232.09c14.4,-3.86 29.2,4.67 33.06,19.06c3.85,14.38 -4.7,29.17 -19.1,33.03c-14.4,3.86 -29.2,-4.67 -33.06,-19.06c-3.85,-14.38 4.7,-29.17 19.1,-33.03z"
android:strokeAlpha="0.8"
android:fillColor="#1f6a50"
android:fillAlpha="0.8" />
<path
android:pathData="m281.58,328.54c26.25,15.27 30.16,38.74 18.87,59.03l0.01,0.01l-40.26,70.19c-2.22,3.83 -7.1,5.13 -10.91,2.92l-12.35,-7.18c-3.81,-2.21 -5.1,-7.11 -2.88,-10.93l20.08,-35.15c-4.89,-1.04 -9.74,-2.87 -14.34,-5.55c-14.36,-8.35 -22.78,-22.8 -23.43,-37.43l21.17,-36.56c13.47,-6.97 30.83,-7.03 44.05,0.66z"
android:fillColor="#f9f9f9"
android:fillType="evenOdd" />
<path
android:pathData="m248.73,352.49c2.77,1.61 3.7,5.18 2.08,7.96c-1.62,2.79 -5.17,3.75 -7.93,2.14c-2.77,-1.61 -3.7,-5.18 -2.08,-7.96c1.62,-2.79 5.17,-3.75 7.93,-2.14z"
android:fillColor="#272727" />
<path
android:pathData="m131.01,300.65c-2.65,4.63 -5.93,8.64 -9.64,11.98l35.02,20.46c3.84,2.21 5.17,7.1 2.98,10.93l-7.1,12.39c-2.19,3.82 -7.08,5.13 -10.91,2.92l-70.08,-40.76l0.01,-0.01c-19.95,-11.96 -28.34,-34.28 -13.25,-60.64c7.6,-13.27 22.66,-21.9 37.83,-22.58l36.67,21.14c6.77,13.02 6.72,29.75 -1.54,44.17z"
android:fillColor="#f9f9f9"
android:fillType="evenOdd" />
<path
android:pathData="m106.46,260.17c2.8,1.61 3.78,5.17 2.19,7.95c-1.59,2.78 -5.15,3.72 -7.95,2.11c-2.8,-1.61 -3.78,-5.17 -2.19,-7.95c1.59,-2.78 5.15,-3.72 7.95,-2.11z"
android:fillColor="#272727" />
</vector>
@@ -1,10 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:bottom="40dp"
android:drawable="@drawable/splash_graphic"
android:left="40dp"
android:right="40dp"
android:top="40dp" />
android:width="48dp"
android:height="48dp"
android:drawable="@mipmap/ic_launcher"
android:gravity="center" />
</layer-list>
@@ -7,7 +7,7 @@
<com.google.android.material.textview.MaterialTextView
android:id="@+id/bluetooth_label"
style="@style/TextAppearance.MaterialComponents.Body1"
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
@@ -20,7 +20,7 @@
<com.google.android.material.textview.MaterialTextView
android:id="@+id/bluetooth_description"
style="@style/TextAppearance.MaterialComponents.Body2"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
@@ -1,34 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/permission_label"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="match_parent"
android:layout_marginTop="32dp"
android:id="@+id/user_time"
style="@style/UserTimeStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="12:23" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/title"
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/overview_nomaindevice_label"
app:layout_constraintBottom_toTopOf="@id/permission_description"
app:layout_constraintBottom_toTopOf="@id/body"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/permission_description"
style="@style/TextAppearance.MaterialComponents.Body2"
android:id="@+id/body"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="32dp"
android:text="@string/overview_nomaindevice_description"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/permission_label" />
app:layout_constraintTop_toBottomOf="@id/title" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -8,7 +8,7 @@
<com.google.android.material.textview.MaterialTextView
android:id="@+id/request_description"
style="@style/TextAppearance.MaterialComponents.Caption"
style="@style/TextAppearance.Material3.LabelMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
@@ -20,7 +20,7 @@
<com.google.android.material.textview.MaterialTextView
android:id="@+id/permission_label"
style="@style/TextAppearance.MaterialComponents.Body1"
style="@style/TextAppearance.Material3.BodyLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
@@ -32,7 +32,7 @@
<com.google.android.material.textview.MaterialTextView
android:id="@+id/permission_description"
style="@style/TextAppearance.MaterialComponents.Body2"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
@@ -44,11 +44,12 @@
<com.google.android.material.button.MaterialButton
android:id="@+id/grant_action"
style="@style/Widget.MaterialComponents.Button"
style="@style/Widget.Material3.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:text="@string/general_grant_permission_action"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/permission_description" />
@@ -7,14 +7,23 @@
android:layout_margin="8dp"
tools:context=".wear.ui.MainActivity">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/user_time"
style="@style/UserTimeStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="12:23" />
<ImageView
android:id="@+id/device_icon"
android:layout_width="16dp"
android:layout_height="16dp"
app:layout_constraintBottom_toTopOf="@+id/name"
android:visibility="invisible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginBottom="2dp"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/devic_airpods_gen1_both" />
@@ -36,15 +45,15 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:gravity="center"
app:lineHeight="14sp"
android:layout_marginBottom="4dp"
android:gravity="center"
android:lines="2"
android:maxLines="2"
app:layout_constraintBottom_toTopOf="@id/barrier_top"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/name"
app:lineHeight="14sp"
tools:text="Music Active, Case Closed" />
<androidx.constraintlayout.widget.Barrier
@@ -60,11 +69,9 @@
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
app:layout_constraintEnd_toStartOf="@id/pod_case_container"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/barrier_top"
app:layout_constraintVertical_bias="0.2">
app:layout_constraintTop_toTopOf="@+id/pod_right_container">
<ImageView
android:id="@+id/pod_left_icon"
@@ -190,8 +197,7 @@
android:layout_marginEnd="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_case_container"
app:layout_constraintTop_toBottomOf="@id/barrier_top"
app:layout_constraintVertical_bias="0.2">
app:layout_constraintTop_toBottomOf="@id/barrier_top">
<ImageView
android:id="@+id/pod_right_icon"
@@ -267,7 +273,7 @@
<com.google.android.material.textview.MaterialTextView
android:id="@+id/last_seen"
style="@style/TextAppearance.Material3.BodySmall"
style="@style/TextAppearance.Material3.LabelSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="32dp"
@@ -7,10 +7,21 @@
android:layout_margin="8dp"
tools:context=".wear.ui.MainActivity">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/user_time"
style="@style/UserTimeStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="12:23" />
<ImageView
android:id="@+id/device_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_width="16dp"
android:layout_height="16dp"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@+id/name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
+6
View File
@@ -15,4 +15,10 @@
<item name="android:gravity">center</item>
<item name="android:ellipsize">end</item>
</style>
<style name="UserTimeStyle" parent="TextAppearance.Material3.LabelMedium">
<item name="android:singleLine">true</item>
<item name="android:gravity">center</item>
<item name="android:ellipsize">marquee</item>
</style>
</resources>
+3 -2
View File
@@ -29,9 +29,10 @@
<item name="colorPrimaryInverse">@color/md_theme_dark_primaryInverse</item>
</style>
<style name="AppThemeSplash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">#3f7aff</item>
<style name="AppThemeSplash" parent="Theme.SplashScreen.IconBackground">
<item name="windowSplashScreenBackground">#000000</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_screen</item>
<item name="windowSplashScreenIconBackgroundColor">#000000</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
</style>
+10 -9
View File
@@ -38,16 +38,18 @@ android {
create("foss") {
dimension = "version"
signingConfig = signingConfigs["releaseFoss"]
// The info block is encrypted and can only be read by google
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
create("gplay") {
dimension = "version"
signingConfig = signingConfigs["releaseGplay"]
extra["useBugsnag"] = true
}
}
setupBugsnagPlugin()
buildTypes {
val customProguardRules = fileTree(File(projectDir, "proguard")) {
include("*.pro")
@@ -128,7 +130,7 @@ android {
dependencies {
implementation(project(":app-common"))
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
addBaseKotlin()
@@ -141,14 +143,13 @@ dependencies {
addBaseAndroid()
addBaseAndroidUi()
implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
addNavigation()
addBaseWorkManager()
addTesting()
"gplayImplementation"("com.android.billingclient:billing:5.1.0")
"gplayImplementation"("com.android.billingclient:billing-ktx:5.1.0")
"gplayImplementation"("com.bugsnag:bugsnag-android:5.9.2")
"gplayImplementation"("com.getkeepsafe.relinker:relinker:1.4.3")
"gplayImplementation"("com.android.billingclient:billing:6.0.1")
"gplayImplementation"("com.android.billingclient:billing-ktx:6.0.1")
}
+5 -1
View File
@@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="foss_upgrade_donate_label">Donează</string>
<string name="foss_upgrade_alreadydonated_label">Am donat deja</string>
<string name="foss_upgrade_no_money_label">Mi-am cheltuit toți banii pe AirPods</string>
</resources>
+3 -3
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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>
<string name="foss_upgrade_donate_label">Bağış yap</string>
<string name="foss_upgrade_alreadydonated_label">Zaten bağış yaptım</string>
<string name="foss_upgrade_no_money_label">Tüm paramı AirPods\'a harcıyorum</string>
</resources>
@@ -1,7 +1,13 @@
package eu.darken.capod.common.upgrade.core.client
import android.app.Activity
import com.android.billingclient.api.*
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.QueryProductDetailsParams
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
@@ -20,11 +26,15 @@ data class BillingClientConnection(
) {
private val purchasesLocal = MutableStateFlow<Collection<Purchase>>(emptySet())
val purchases: Flow<Collection<Purchase>> = combine(purchasesGlobal, purchasesLocal) { global, local ->
val combined = mutableMapOf<String, Purchase>()
global.plus(local).toSet().sortedByDescending { it.purchaseTime }.forEach { purchase ->
combined[purchase.orderId] = purchase
}
combined.values
val combined = mutableSetOf<Purchase>()
combined.addAll(local)
global
.let { purchases -> purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED } }
.let { combined.addAll(it) }
combined.sortedByDescending { it.purchaseTime }
}
.setupCommonEventHandlers(TAG) { "purchases" }
+4 -1
View File
@@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="upgrades_gplay_unavailable_error">Serviciile Google Play nu sunt disponibile.</string>
<string name="upgrades_no_purchases_found_check_account">Nu s-au găsit achiziții. Folosești contul corect?</string>
</resources>
+12 -3
View File
@@ -9,6 +9,9 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
@@ -24,9 +27,9 @@
<activity
android:name=".main.ui.MainActivity"
android:exported="true"
android:theme="@style/AppThemeSplash"
android:label="@string/app_name"
android:launchMode="singleTop">
android:launchMode="singleTop"
android:theme="@style/AppThemeSplash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -74,7 +77,13 @@
android:name=".common.debug.recording.ui.RecorderActivity"
android:theme="@style/AppThemeFloating" />
<service android:name=".common.debug.recording.core.RecorderService" />
<service
android:name=".common.debug.recording.core.RecorderService"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Users can record a debug log to help developers with bugs. The debug log recording is controlled via notifications and hosted in a foreground service." />
</service>
<!-- Worker stuff-->
<service
@@ -0,0 +1,42 @@
package eu.darken.capod.common.preferences
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.util.AttributeSet
import android.widget.Toast
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.preference.Preference
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
class IntentPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle,
@StyleRes defStyleRes: Int = 0,
) : Preference(context, attrs, defStyleAttr, defStyleRes) {
override fun setIntent(_intent: Intent?) {
super.setIntent(_intent)
_intent?.let {
intent
setOnPreferenceClickListener {
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
log(TAG, ERROR) { "Failed to launch $intent: $e" }
Toast.makeText(context, e.toString(), Toast.LENGTH_LONG).show()
}
true
}
}
}
companion object {
private val TAG = logTag("IntentPreference")
}
}
@@ -53,7 +53,7 @@ abstract class PreferenceFragment2
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
}
@@ -0,0 +1,29 @@
package eu.darken.capod.main.ui.onboarding
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.uix.Fragment3
import eu.darken.capod.common.viewbinding.viewBinding
import eu.darken.capod.databinding.OnboardingFragmentBinding
import javax.inject.Inject
@AndroidEntryPoint
class OnboardingFragment : Fragment3(R.layout.onboarding_fragment) {
override val vm: OnboardingFragmentVM by viewModels()
override val ui: OnboardingFragmentBinding by viewBinding()
@Inject lateinit var webpageTool: WebpageTool
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
ui.goPrivacyPolicy.setOnClickListener { webpageTool.open(PrivacyPolicy.URL) }
ui.continueAction.setOnClickListener { vm.finishOnboarding() }
super.onViewCreated(view, savedInstanceState)
}
}
@@ -0,0 +1,27 @@
package eu.darken.capod.main.ui.onboarding
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.main.core.GeneralSettings
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@HiltViewModel
class OnboardingFragmentVM @Inject constructor(
@Suppress("UNUSED_PARAMETER") handle: SavedStateHandle,
private val dispatcherProvider: DispatcherProvider,
private val generalSettings: GeneralSettings,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
fun finishOnboarding() = launch {
generalSettings.isOnboardingDone.value = true
OnboardingFragmentDirections.actionOnboardingFragmentToOverviewFragment().navigate()
}
companion object {
val TAG = logTag("Onboarding", "Fragment", "VM")
}
}
@@ -8,6 +8,8 @@ import android.text.SpannableStringBuilder
import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.BuildConfig
@@ -147,6 +149,7 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
vm.launchUpgradeFlow.observe2 {
it(requireActivity())
}
super.onViewCreated(view, savedInstanceState)
}
@@ -11,7 +11,6 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.navigation.navVia
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.common.upgrade.UpgradeRepo
@@ -48,6 +47,12 @@ class OverviewFragmentVM @Inject constructor(
private val bluetoothManager: BluetoothManager2,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
init {
if (!generalSettings.isOnboardingDone.value) {
OverviewFragmentDirections.actionOverviewFragmentToOnboardingFragment().navigate()
}
}
val upgradeState = upgradeRepo.upgradeInfo.asLiveData2()
val launchUpgradeFlow = SingleLiveEvent<(Activity) -> Unit>()
@@ -124,7 +129,7 @@ class OverviewFragmentVM @Inject constructor(
items.add(0, BluetoothDisabledVH.Item)
} else if (mainPod == null) {
items.add(0, MissingMainDeviceVH.Item {
OverviewFragmentDirections.actionOverviewFragmentToTroubleShooterFragment().navVia(this)
OverviewFragmentDirections.actionOverviewFragmentToTroubleShooterFragment().navigate()
})
}
}
@@ -140,12 +145,14 @@ class OverviewFragmentVM @Inject constructor(
showDebug = isDebugMode,
isMainPod = isMainPod,
)
is SinglePodDevice -> SinglePodsCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = isMainPod,
)
else -> UnknownPodDeviceCardVH.Item(
now = now,
device = it,
@@ -166,7 +173,7 @@ class OverviewFragmentVM @Inject constructor(
}
fun goToSettings() = launch {
OverviewFragmentDirections.actionOverviewFragmentToSettingsFragment().navVia(this@OverviewFragmentVM)
OverviewFragmentDirections.actionOverviewFragmentToSettingsFragment().navigate()
}
fun onUpgrade() = launch {
@@ -2,7 +2,10 @@ package eu.darken.capod.main.ui.settings
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ListView
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.preference.Preference
@@ -15,6 +18,7 @@ import eu.darken.capod.common.viewbinding.viewBinding
import eu.darken.capod.databinding.SettingsFragmentBinding
import kotlinx.parcelize.Parcelize
@AndroidEntryPoint
class SettingsFragment : Fragment2(R.layout.settings_fragment),
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
@@ -1,19 +1,19 @@
package eu.darken.capod.main.ui.settings.general
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.DialogInterface
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BluetoothDevice2
class DeviceSelectionDialogFactory constructor(private val context: Context) {
fun create(
devices: List<BluetoothDevice>,
current: BluetoothDevice?,
callback: (BluetoothDevice?) -> Unit
devices: List<BluetoothDevice2>,
current: BluetoothDevice2?,
callback: (BluetoothDevice2?) -> Unit
): AlertDialog {
return MaterialAlertDialogBuilder(context).apply {
setTitle(R.string.settings_maindevice_address_label)
@@ -30,7 +30,6 @@ class GeneralSettingsFragmentVM @Inject constructor(
val events = SingleLiveEvent<GeneralSettingsEvents>()
init {
generalSettings.monitorMode.flow
.withPrevious()
.filter { (old, new) ->
@@ -4,7 +4,6 @@ import android.os.Bundle
import android.view.View
import androidx.annotation.Keep
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
@@ -27,17 +26,6 @@ class SupportFragment : PreferenceFragment3() {
@Inject lateinit var clipboardHelper: ClipboardHelper
private val installIdPref by lazy { findPreference<Preference>("support.installid")!! }
override fun onPreferencesCreated() {
installIdPref.setOnPreferenceClickListener {
vm.copyInstallID()
true
}
super.onPreferencesCreated()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
vm.clipboardEvent.observe2(this) { installId ->
Snackbar.make(requireView(), installId, Snackbar.LENGTH_INDEFINITE)
@@ -8,6 +8,7 @@ import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.RemoteViews
import androidx.annotation.LayoutRes
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.coroutine.AppScope
@@ -41,6 +42,7 @@ import kotlinx.coroutines.withTimeout
import java.time.Duration
import javax.inject.Inject
@AndroidEntryPoint
class WidgetProvider : AppWidgetProvider() {
@@ -95,6 +97,27 @@ class WidgetProvider : AppWidgetProvider() {
}
}
/**
* Returns number of cells needed for given size of the widget.
*
* The value is determined in accordance with official guidelines
* for designing widgets, see:
* https://developer.android.com/guide/practices/ui_guidelines/widget_design
*
* Thanks to Jakub S. on Stackoverflow for this solution:
* https://stackoverflow.com/a/37522648/10866268
*
* @param size Widget size in dp.
* @return Size in number of cells.
*/
private fun getCellsForSize(size: Int): Int {
var n = 2
while (70 * n - 30 < size) {
++n
}
return n - 1
}
private suspend fun updateWidget(
context: Context,
widgetManager: AppWidgetManager,
@@ -107,7 +130,23 @@ class WidgetProvider : AppWidgetProvider() {
val layout = when {
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context)
device is DualPodDevice -> createDualPodLayout(context, device)
device is DualPodDevice -> {
val minWidth = widgetManager.getAppWidgetOptions(widgetId)
.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
val columns = getCellsForSize(minWidth)
/* Enable wide widgets only when we are provided with 5 or
* more columns of space.
* Although the minimum size is only 2x1, the safeguards are
* added if these restrictions will ever be loosened in the future.
*/
val layout = when (columns) {
in 1 .. 4 -> R.layout.widget_pod_dual_compact_layout
else -> R.layout.widget_pod_dual_wide_layout
}
createDualPodLayout(context, device, layout)
}
device is SinglePodDevice -> createSinglePodLayout(context, device)
device is PodDevice -> createUnknownPodLayout(context, device)
else -> createNoDeviceLayout(context)
@@ -169,8 +208,9 @@ class WidgetProvider : AppWidgetProvider() {
private fun createDualPodLayout(
context: Context,
podDevice: DualPodDevice,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_dual_layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
@LayoutRes layout: Int
): RemoteViews = RemoteViews(context.packageName, layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice), layout=${layout}" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
@@ -1,7 +1,6 @@
package eu.darken.capod.monitor.core.worker
import android.app.NotificationManager
import android.bluetooth.BluetoothDevice
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
@@ -9,6 +8,7 @@ import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
@@ -30,8 +30,20 @@ import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause
import eu.darken.capod.reaction.core.popup.PopUpReaction
import eu.darken.capod.reaction.ui.popup.PopUpWindow
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
@HiltWorker
@@ -68,8 +80,6 @@ class MonitorWorker @AssistedInject constructor(
val start = System.currentTimeMillis()
log(TAG, VERBOSE) { "Executing $inputData now (runAttemptCount=$runAttemptCount)" }
setForeground(monitorNotifications.getForegroundInfo(null))
doDoWork()
val duration = System.currentTimeMillis() - start
@@ -96,6 +106,8 @@ class MonitorWorker @AssistedInject constructor(
return
}
setForeground(monitorNotifications.getForegroundInfo(null))
val monitorJob = podMonitor.mainDevice
.setupCommonEventHandlers(TAG) { "PodMonitor" }
.distinctUntilChanged()
@@ -130,7 +142,7 @@ class MonitorWorker @AssistedInject constructor(
.flatMapLatest { arguments ->
val monitorMode = arguments[0] as MonitorMode
val mainAddress = arguments[1] as String?
val devices = arguments[2] as Collection<BluetoothDevice>
val devices = arguments[2] as Collection<BluetoothDevice2>
log(TAG) { "Monitor mode: $monitorMode" }
when (monitorMode) {
@@ -145,7 +157,7 @@ class MonitorWorker @AssistedInject constructor(
log(TAG, WARN) { "Main device address not set, staying alive while any is connected" }
}
devices.any { it.address == mainAddress } -> {
log(TAG) { "MainDevice is connected ($mainAddress), aborting any timeout." }
log(TAG) { "Main device is connected ($mainAddress), aborting any timeout." }
}
else -> {
log(TAG) { "No known Pods are connected, canceling worker soon." }
@@ -19,7 +19,16 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.getBatteryLevelCase
import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.getBatteryLevelLeftPod
import eu.darken.capod.pods.core.getBatteryLevelRightPod
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
@@ -71,9 +80,65 @@ class MonitorNotifications @Inject constructor(
}
return builder.apply {
// Options here should be mutually exclusive, and are prioritized by their order of importance
// Some options are omitted here, as they will conflict with other options
// TODO: Implement a settings pane to allow user to customize this
val stateText = when {
// Pods charging state
// This goes first as pods should not be worn if it is still charging
device is HasChargeDetection && device.isHeadsetBeingCharged -> {
context.getString(eu.darken.capod.common.R.string.pods_charging_label)
}
// Pods wear state
device is HasEarDetection -> {
if (device.isBeingWorn) context.getString(eu.darken.capod.common.R.string.headset_being_worn_label)
else context.getString(eu.darken.capod.common.R.string.headset_not_being_worn_label)
}
// Case charge state
// This is under pods wear state as we don't want it conflicting with it
device is HasCase && device.isCaseCharging -> {
context.getString(eu.darken.capod.common.R.string.pods_charging_label)
}
else -> context.getString(eu.darken.capod.common.R.string.pods_case_unknown_state)
}
val batteryText = when (device) {
is DualPodDevice -> {
val left = device.getBatteryLevelLeftPod(context)
val right = device.getBatteryLevelRightPod(context)
when {
device is HasCase -> {
val case = device.getBatteryLevelCase(context)
"$left $case $right"
}
else -> "$left $right"
}
}
is SinglePodDevice -> {
val headset = device.getBatteryLevelHeadset(context)
when {
device is HasCase -> {
val case = device.getBatteryLevelCase(context)
"$headset $case"
}
else -> headset
}
}
else -> "?"
}
setStyle(NotificationCompat.DecoratedCustomViewStyle())
setCustomContentView(notificationViewFactory.createContentView(device))
setCustomBigContentView(notificationViewFactory.createContentView(device))
setSmallIcon(device.iconRes)
setContentTitle("$batteryText ~ $stateText")
setSubText(null)
log(TAG, VERBOSE) { "updatingNotification(): $device" }
}
@@ -1,16 +1,16 @@
package eu.darken.capod.reaction.ui
import android.bluetooth.BluetoothDevice
import android.os.Bundle
import android.view.View
import androidx.annotation.Keep
import androidx.fragment.app.viewModels
import androidx.lifecycle.asLiveData
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.TwoStatePreference
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.uix.PreferenceFragment3
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
@@ -36,7 +36,7 @@ class ReactionSettingsFragment : PreferenceFragment3() {
override val preferenceFile: Int = R.xml.preferences_reactions
private var isPro: Boolean = false
private var bondedDevices: List<BluetoothDevice> = emptyList()
private var bondedDevices: List<BluetoothDevice2> = emptyList()
private val autoConnectConditionPref by lazy { findPreference<ListPreference>(settings.autoConnectCondition.key)!! }
override fun onPreferencesCreated() {
@@ -51,17 +51,17 @@ class ReactionSettingsFragment : PreferenceFragment3() {
override fun onPreferenceTreeClick(preference: Preference): Boolean {
if (preference.key == reactionSettings.autoPlay.key && !isPro) {
preference as CheckBoxPreference
preference as TwoStatePreference
upgradeRepo.launchBillingFlow(requireActivity())
preference.isChecked = false
return true
} else if (preference.key == reactionSettings.autoPause.key && !isPro) {
preference as CheckBoxPreference
preference as TwoStatePreference
upgradeRepo.launchBillingFlow(requireActivity())
preference.isChecked = false
return true
} else if (preference.key == reactionSettings.autoConnect.key) {
preference as CheckBoxPreference
preference as TwoStatePreference
if (!isPro) {
upgradeRepo.launchBillingFlow(requireActivity())
@@ -79,7 +79,12 @@ class ReactionSettingsFragment : PreferenceFragment3() {
return true
}
} else if (preference.key == reactionSettings.showPopUpOnCaseOpen.key && !isPro) {
preference as CheckBoxPreference
preference as TwoStatePreference
upgradeRepo.launchBillingFlow(requireActivity())
preference.isChecked = false
return true
} else if (preference.key == reactionSettings.showPopUpOnConnection.key && !isPro) {
preference as TwoStatePreference
upgradeRepo.launchBillingFlow(requireActivity())
preference.isChecked = false
return true
@@ -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"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M20,2H4A2,2 0 0,0 2,4V22L6,18H20A2,2 0 0,0 22,16V4C22,2.89 21.1,2 20,2Z" />
</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"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M20 2H4C2.9 2 2 2.9 2 4V22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2M20 16H5.2L4 17.2V4H20V16Z" />
</vector>
@@ -13,7 +13,7 @@
<TextView
android:id="@+id/label_path"
style="@style/TextAppearance.MaterialComponents.Caption"
style="@style/TextAppearance.Material3.TitleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
@@ -30,7 +30,7 @@
<TextView
android:id="@+id/recording_path"
style="@style/TextAppearance.MaterialComponents.Body1"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
@@ -44,7 +44,7 @@
<TextView
android:id="@+id/label_size"
style="@style/TextAppearance.MaterialComponents.Caption"
style="@style/TextAppearance.Material3.TitleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="32dp"
@@ -58,7 +58,7 @@
<TextView
android:id="@+id/recording_size"
style="@style/TextAppearance.AppCompat.Body1"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="32dp"
@@ -70,7 +70,7 @@
<TextView
android:id="@+id/label_compressed_size"
style="@style/TextAppearance.MaterialComponents.Caption"
style="@style/TextAppearance.Material3.TitleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
@@ -83,7 +83,7 @@
<TextView
android:id="@+id/recording_size_compressed"
style="@style/TextAppearance.AppCompat.Body1"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="@+id/label_compressed_size"
+3 -1
View File
@@ -7,7 +7,7 @@
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/Widget.MaterialComponents.Toolbar.Primary"
style="@style/Widget.Material3.Toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
@@ -22,6 +22,8 @@
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:paddingBottom="48dp"
android:clipToPadding="false"
tools:listitem="@layout/overview_pods_dual_item"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="32dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/mascot"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_marginTop="48dp"
android:src="@drawable/splash_graphic2"
app:layout_constraintEnd_toEndOf="@id/title"
app:layout_constraintStart_toStartOf="@id/title"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/title"
style="@style/TextAppearance.Material3.HeadlineLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/app_name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/mascot" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/body1"
style="@style/TextAppearance.Material3.BodyLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/onboarding_body1"
app:layout_constraintTop_toBottomOf="@id/title" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/body2"
style="@style/TextAppearance.Material3.BodyLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/onboarding_body2"
app:layout_constraintTop_toBottomOf="@id/body1" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/body3"
style="@style/TextAppearance.Material3.BodyLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/onboarding_body3"
app:layout_constraintTop_toBottomOf="@id/body2" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/body4"
style="@style/TextAppearance.Material3.BodyLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/onboarding_body4"
app:layout_constraintTop_toBottomOf="@id/body3" />
<com.google.android.material.button.MaterialButton
android:id="@+id/go_privacy_policy"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginVertical="32dp"
android:text="@string/settings_privacy_policy_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/body4" />
<com.google.android.material.button.MaterialButton
android:id="@+id/continue_action"
style="@style/Widget.Material3.Button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="32dp"
android:text="@string/general_continue_action"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/go_privacy_policy" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

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