Compare commits

...
110 Commits
Author SHA1 Message Date
darken c1b247c219 Release: 2.17.0-rc0 2025-06-06 07:47:56 +02:00
darken 101fb68205 Translate strings for new notification channel and update descriptions
This commit introduces translations for a new notification channel "Device connected" and updates some existing string descriptions in various languages.

Specifically, it:
- Adds translations for `settings_monitor_connected_notification_label`, `settings_monitor_connected_notification_description`, and `notification_channel_device_status_connected_label` in Spanish (es), French (fr), Estonian (et-rEE), Norwegian (no), Czech (cs), Polish (pl), Catalan (ca), Greek (el), and German (de).
- Updates the full app descriptions in `fastlane/metadata/android/` for Catalan (ca), German (de-DE), Spanish (es-ES), French (fr-FR), Estonian (et), Norwegian (no-NO), Czech (cs-CZ), Polish (pl-PL), and Russian (ru-RU) to replace "phone" with "device" in the context of connecting to AirPods.
- Adds missing Arabic (ar) translations for various strings related to debug logging, settings, troubleshooting, and onboarding.
- Fixes a typo in the German translation for `settings_general_description`.
- Corrects Hebrew (iw) translations for Bluetooth permission descriptions.
2025-06-06 06:57:29 +02:00
darken cda16ca06a Extend tests and fix payload parsing
This commit enhances the test coverage for `AirPodsProTest` by:
- Verifying the hex representation of the public payload data.
- Confirming that the private payload is null when no IRK/EncKey is set.
- Verifying the hex representation of the private payload data when IRK/EncKey are set.

Additionally, a fix is introduced in `AppleFactory.kt` to correctly parse the public payload by taking only the first 9 bytes instead of 16. This ensures that the private payload is only processed if the incoming message length matches the expected `PAIRING_MESSAGE_LENGTH`.
2025-06-06 06:03:41 +02:00
darken e9b29a2a43 Prioritize private payload for battery info in single headset devices
This commit modifies `SingleApplePods`, `AirPodsMaxUsbc`, and `AirPodsMax` to prioritize battery information from the decrypted private payload when available.

Specifically:
- `SingleApplePods.batteryHeadsetPercent`: Now checks `payload.private?.asBatteryState(1)?.level` first. If the private payload is not available or doesn't contain battery information at position 1, it falls back to the existing logic using `pubPodsBattery`.
- `AirPodsMaxUsbc.isHeadsetBeingCharged` and `AirPodsMax.isHeadsetBeingCharged`: Now check `payload.private?.asBatteryState(1)?.isCharging` first. If the private payload is not available or doesn't contain charging status at position 1, they fall back to the existing logic using `pubFlags.isBitSet(0)`.

This change allows for more accurate battery level and charging status reporting when the private payload can be decrypted.
2025-06-06 06:03:41 +02:00
darken 5df809a492 Improve main device determination logic
This commit refines the logic for selecting the main Pod device. Previously, the `RPAChecker` was used directly to verify the Identity Resolving Key (IRK).

The new approach leverages the `isIRKMatch` flag within the `ApplePods` class. This flag is set during the device parsing process, indicating whether the device's address matches the configured IRK.

This change:
- Simplifies the `PodMonitor` by removing the direct dependency on `RPAChecker`.
- Encapsulates the IRK matching logic within the `ApplePods` class, improving modularity.
- Ensures that the main device selection correctly prioritizes devices whose IRK has been matched.
2025-06-06 06:03:41 +02:00
darken ffe8a58746 Introduce ApplePods.Flags and UI for key status
This commit introduces a new `Flags` data class within `ApplePods` to store boolean flags related to the device, specifically `isIRKMatch`.

This flag is now passed during the creation of `ApplePods` instances in all `ApplePodsFactory` implementations and the central `AppleFactory`.

The UI has been updated to display a key icon in the overview cards (`DualPodsCardVH` and `SinglePodsCardVH`):
- The icon is only visible if `flags.isIRKMatch` is true.
- The icon changes to a filled key (`ic_key_24`) if `payload.private` is not null (indicating successful decryption), otherwise an outline key (`ic_key_outline_24`) is shown.
- Minor layout adjustments were made in `overview_pods_single_item.xml` and `overview_pods_dual_item.xml` to accommodate the new key icon.
2025-06-06 06:03:41 +02:00
darken eab0296125 Introduce helper for parsing battery state
This commit introduces a new `BatteryState` data class and an extension function `ProximityPayload.Private.asBatteryState` to simplify the parsing of battery level and charging status from the proximity payload.

The `asBatteryState` function takes the position of the battery data in the payload and returns a `BatteryState` object, or null if the data is invalid.

This change improves code readability and maintainability by encapsulating the battery state parsing logic.
2025-06-06 06:03:41 +02:00
darken 2ff143b858 Determine case charging state more accurately
The previous logic for determining if the case was charging relied on the `batteryCasePercent` being non-null. This commit refines the logic to check if the `batteryCasePercent` falls within the valid range of 0.0 to 1.0, providing a more accurate assessment of the case's charging status.
2025-06-06 06:03:41 +02:00
darken 3b2dcc1a5c Refactor Pods history to a central PodHistoryRepo
This moves the history tracking logic for discovered Apple Pods from individual `ApplePodsFactory` implementations to a new `PodHistoryRepo` class.

Benefits:
- Centralized logic for device history management.
- Simplifies individual `ApplePodsFactory` implementations.
- Improves code maintainability and testability.
2025-06-06 06:03:41 +02:00
darken d59dc8c225 Move RPA checker and key types
Relocate RPAChecker to the `pods.core.apple.protocol` package as it's specific to Apple's protocol.

Introduce type aliases `IdentityResolvingKey` and `ProximityEncryptionKey` for `ByteArray` to improve code clarity and type safety when dealing with these keys. Update relevant classes to use these new type aliases.
2025-06-06 06:03:41 +02:00
darken 877c5d9013 Introduce BluetoothAddress typealias
This commit introduces a typealias `BluetoothAddress` for `String` to improve code clarity and type safety when dealing with Bluetooth MAC addresses.

The following changes were made:
- Created `BluetoothAddress.kt` defining the typealias.
- Updated various classes to use `BluetoothAddress` instead of `String` for Bluetooth addresses:
    - `RPAChecker`
    - `PopUpReaction`
    - `DeviceSelectionDialogFactory`
    - `MonitorWorker`
    - `GeneralSettings`
    - `BluetoothDevice2`
2025-06-06 06:03:41 +02:00
darken 11a19da0b8 Add helper for HEX string to byte array conversion
This commit introduces a new helper function `fromHex()` that converts a HEX string (with optional separators like spaces or hyphens) into a byte array. It also adds a corresponding `toHex()` function for converting a byte array to a HEX string.

These helpers are now used in various parts of the codebase, including:
- Fake BLE data generation
- AirPod key input dialog
- RPA checker tests
- BaseAirPodsTest

Additionally, a unit test for these conversion functions has been added.
2025-06-06 06:03:41 +02:00
darken eda81822aa Add more tests for AirPods data decryption
This commit adds a test case to `AirPodsProTest` to verify the decryption of AirPods Pro data with and without encryption/IRK keys. It also introduces a new test class `RPACheckerTest` to validate the RPA checking mechanism.

Helper functions for converting hex strings to byte arrays and setting mock keys have been added to `BaseAirPodsTest` to simplify test setup.
2025-06-06 06:03:41 +02:00
darken 3b00613dde Refactor proximity message handling, introduce "Payload" that splits the data in public and private part. 2025-06-06 06:03:41 +02:00
darken a434a0bee0 Disable encryption key setting if no identity key
The encryption key setting for the main device is now disabled if the identity key is not set. This prevents users from trying to set an encryption key for a device that doesn't have an identity key.
2025-06-06 06:03:41 +02:00
darken 33ca43eaf1 Use decrypted proximity data if available
This change introduces the capability to decrypt the last 16 bytes of the proximity pairing message if an encryption key is available.

The decrypted payload provides more precise battery information (0-100% instead of 0-10%) and charging status for each pod and the case.

This information is now used by `DualApplePods` to enhance battery reporting accuracy.

The `RPAChecker` has also been updated with improved error logging.
2025-06-06 06:03:41 +02:00
darken df4ec3501b Add resolvable private address check
This allows verifying the identity of a device if its Identity Resolving Key (IRK) is known.
The IRK can be optionally configured.
2025-06-06 06:03:41 +02:00
darken fc54e540e8 Code fluff
Replace `values()` with `entries` for enum iteration, as recommended by Kotlin for improved performance and consistency.

Also:
- Suppress unused warning in `ByteArrayAdapter`
- Improve logging in `WebpageTool` by including the exception details.
2025-06-06 06:03:41 +02:00
darken e8481cc8cd Add preferences for AirPods Identity and Encryption Keys
This commit introduces settings for users to input their AirPods' Identity Resolving Key (IRK) and Encryption Key. These keys, obtainable via a MacBook, allow CAPod to reliably identify the AirPods and decrypt detailed status information.

Key changes:
- Added new preferences in General Settings for IRK and Encryption Key.
- Implemented a custom dialog for key input, including validation and a link to a guide.
- Added a Moshi adapter for serializing/deserializing `ByteArray` to/from Base64 for storing the keys.
- Included new string resources for labels, descriptions, and explanations related to the keys.
- Added new icons for the key preferences.
2025-06-06 06:03:41 +02:00
darken eb8a9b3b46 Add option for separate connected notification
Closes #28
Closes #18
2025-05-14 21:21:04 +02:00
darken 79ec3ba932 Remove obsolete configuration in manifest 2025-05-14 21:04:28 +02:00
darken 8f910b6dcc Group reaction preferences into categories 2025-05-14 19:35:54 +02:00
darken 92fa181925 Decrease default minimum signal quality 2025-05-14 19:35:47 +02:00
darken ca82f96b30 Update translations 2025-05-13 19:54:01 +02:00
darken 2ea2bb1765 Update Play Billing library to 7.1.1 2025-05-13 19:02:02 +02:00
darken fb22798469 Use correct log levels for workmanager 2025-05-13 19:02:02 +02:00
darken d48f475a34 Update desugar_jdk_libs to 2.1.5 2025-05-13 19:02:02 +02:00
darken c88c57e856 Update gradle dependencies and configurations 2025-05-13 19:02:02 +02:00
darken 17892fbd24 Fix test runner 2025-04-11 03:02:23 +02:00
darken 2352974d4d Update github actions (based on SDMSE) and use pinning 2025-04-11 03:02:23 +02:00
darken e216fbf66e Update privacy policy link 2025-03-21 19:36:03 +01:00
Matthias Urhahn f3a947a8a3 Update PRIVACY_POLICY.md 2025-03-21 19:36:03 +01:00
darken 00264c1d2b Remove link to Twitter 2025-03-21 19:24:07 +01:00
darken 54a02837fd Update description with feedback from Crowdin 2025-03-21 19:23:06 +01:00
Matthias Urhahn 7a5af76ebb Create CNAME 2025-03-19 22:30:14 +01:00
Matthias Urhahn 92bd754623 Create plain.html 2025-03-19 22:29:09 +01:00
Matthias Urhahn 85b9978e05 Create _config.yml 2025-03-19 22:28:41 +01:00
darken 1ebb2319d4 Add thanks note 2024-12-05 13:43:06 +01:00
darken f956625d2e Update translations 2024-12-05 13:43:06 +01:00
darken 9c2ebd9d04 Update CLI tool 2024-12-05 13:43:06 +01:00
darken fb0527b0bf Release: 2.16.0-rc0 2024-11-08 11:18:14 +01:00
darken 6b922e8845 Fix AirPods Max and AirPods Max (USB-C) support
Closes #236
2024-11-08 09:24:44 +01:00
b4shtirk1n 663e2d3b08 add support airpods max 2 & fix airpods max 2024-11-08 08:23:46 +01:00
darken 858b86af6a Fix onboarding not being completable with large fontsizes 2024-09-30 16:17:11 +02:00
darken 647300fd3a Release: 2.15.0-rc0 2024-09-23 15:05:48 +02:00
darken 3eaf99390e Record debug logs without foreground service
Some overhaul of log recording to get rid of the `FOREGROUND_SERVICE_SPECIAL_USE` which is a headache with Google
2024-09-23 15:05:11 +02:00
darken 0ee7915e31 Fix WorkManager init crash
```
Caused by java.lang.IllegalStateException: WorkManager is not initialized properly.  You have explicitly disabled WorkManagerInitializer in your manifest, have not manually called WorkManager#initialize at this point, and your Application does not implement Configuration.Provider.
  at androidx.work.impl.WorkManagerImpl.getInstance (WorkManagerImpl.java)
  at androidx.work.impl.foreground.SystemForegroundDispatcher.<init> (SystemForegroundDispatcher.java)
  at androidx.work.impl.foreground.SystemForegroundService.initializeDispatcher (SystemForegroundService.java)
  at androidx.work.impl.foreground.SystemForegroundService.onCreate (SystemForegroundService.java)
  at android.app.ActivityThread.handleCreateService (ActivityThread.java:4726)
```
2024-09-23 14:12:25 +02:00
darken 10c880a800 Do staged rollouts via fastlane 2024-09-23 13:53:18 +02:00
Matthias Urhahn b1bf658ff3 Update README.md 2024-09-23 13:51:09 +02:00
Matthias Urhahn 77dc13d9ba Merge pull request #228 from d4rken-org/airpods-gen4-ANC
Add support for AirPods (Gen 4 with ANC)
2024-09-23 13:41:21 +02:00
darken 21b6cfc013 Add support for AirPods (Gen 4 with ANC)
Closes #226
2024-09-23 13:33:12 +02:00
Matthias Urhahn 5d953ee23e Merge pull request #227 from d4rken-org/airpods-gen4
Add support for AirPods (Gen 4 - no ANC)
2024-09-23 13:32:30 +02:00
darken 42f2f5bfee Add support for AirPods (Gen 4)
Closes #225
2024-09-23 13:08:35 +02:00
darken 6708901394 Improve BLE scanning
The default "balanced" mode was reporting data too infrequently.
2024-07-26 08:48:21 +02:00
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
365 changed files with 4909 additions and 2457 deletions
+42
View File
@@ -0,0 +1,42 @@
name: 'Common project setup'
description: 'Setup the base project'
runs:
using: "composite"
steps:
- name: Set up JDK 17
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
with:
java-version: '17'
distribution: 'temurin'
- name: Grant execute permission for gradlew
shell: bash
run: chmod +x gradlew
- name: Cache Gradle Wrapper
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
with:
path: |
~/.gradle/wrapper
!~/.gradle/wrapper/dists/**/gradle*.zip
key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-wrapper-
- name: Cache Gradle Dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
with:
path: |
~/.gradle/caches
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
restore-keys: |
${{ runner.os }}-gradle-caches-
- name: Cache Android Global Build-Cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
with:
path: |
~/.android/build-cache
key: ${{ runner.os }}-android-build-cache-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-android-build-cache-
+14
View File
@@ -0,0 +1,14 @@
changelog:
exclude:
labels:
- changelog-ignore
categories:
- title: ":rocket: Enhancements"
labels:
- enhancement
- title: ":lady_beetle: Bug fixes"
labels:
- bug
- title: ":shrug: Other changes"
labels:
- "*"
+31 -23
View File
@@ -7,6 +7,24 @@ on:
branches: [ main ]
jobs:
lint-vital:
name: Lint vitals
strategy:
fail-fast: false
matrix:
flavor: [ Foss,Gplay ]
variant: [ Beta,Release ]
module: [ app ]
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Check for fatal lint issues
run: ./gradlew lintVital${{ matrix.flavor }}${{ matrix.variant }}
build-modules:
name: Build apps
strategy:
@@ -14,20 +32,15 @@ jobs:
matrix:
flavor: [ Foss,Gplay ]
variant: [ Debug ]
module: [ app,app-wear ]
runs-on: ubuntu-latest
module: [ app ]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'adopt'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Build module
- name: Build modules
run: ./gradlew ${{ matrix.module }}:assemble${{ matrix.flavor }}${{ matrix.variant }}
test-modules:
@@ -35,19 +48,14 @@ jobs:
strategy:
fail-fast: false
matrix:
variant: [ Release ]
variant: [ Debug,Beta,Release ]
flavor: [ testFoss,testGplay ]
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'adopt'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Test modules
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
@@ -11,7 +11,7 @@ on:
jobs:
validation:
name: "Validation"
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: gradle/wrapper-validation-action@v1
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 #v4.3.1
+21 -27
View File
@@ -8,7 +8,9 @@ on:
jobs:
release-github:
name: Create GitHub release
runs-on: ubuntu-latest
permissions:
contents: write
runs-on: ubuntu-22.04
environment: foss-production
steps:
- name: Decode Keystore
@@ -21,25 +23,22 @@ jobs:
echo $ENCODED_KEYSTORE | base64 -di > "${TMP_KEYSTORE_FILE_PATH}"
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@v3
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
with:
fetch-depth: 0
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Get the version
id: tagger
uses: jimschubert/query-tag-action@v2
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
with:
skip-unshallow: 'true'
abbrev: false
commit-ish: HEAD
- name: Install JDK ${{ matrix.java_version }}
uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: 17
- name: Assemble beta APK
if: contains(steps.tagger.outputs.tag, '-beta')
run: ./gradlew assembleFossBeta
@@ -60,7 +59,7 @@ jobs:
- name: Create pre-release
if: contains(steps.tagger.outputs.tag, '-beta')
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
with:
prerelease: true
tag_name: ${{ steps.tagger.outputs.tag }}
@@ -74,21 +73,19 @@ jobs:
- name: Create release
if: "!contains(steps.tagger.outputs.tag, '-beta')"
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
with:
prerelease: false
tag_name: ${{ steps.tagger.outputs.tag }}
name: ${{ steps.tagger.outputs.tag }}
generate_release_notes: true
files: |
app/build/outputs/apk/foss/release/*.apk
app-wear/build/outputs/apk/foss/release/*.apk
files: app/build/outputs/apk/foss/release/*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release-gplay:
name: Create Google Play release
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
environment: gplay-production
steps:
- name: Decode Keystore
@@ -111,29 +108,26 @@ jobs:
echo $ENCODED_SERVICE_KEY | base64 -di > "${TMP_SERVICEKEY_FILE_PATH}"
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@v3
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
with:
fetch-depth: 0
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Get the version
id: tagger
uses: jimschubert/query-tag-action@v2
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
with:
skip-unshallow: 'true'
abbrev: false
commit-ish: HEAD
- name: Install JDK ${{ matrix.java_version }}
uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: 17
- name: Set up ruby env
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 #v1.229.0
with:
ruby-version: 2.7.6
ruby-version: 3.3.6
bundler-cache: true
# - name: Assemble WearOS beta and upload to Google Play
+1
View File
@@ -9,6 +9,7 @@
<option name="DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION" value="true" />
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="" withSubpackages="true" static="false" module="true" />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
+1
View File
@@ -0,0 +1 @@
capod.darken.eu
+7 -1
View File
@@ -1,5 +1,11 @@
---
layout: plain
permalink: /privacy
title: "Privacy Policy"
---
# Privacy policy
This is the privacy policy for the Android app "CAPod - Companion for AirPods".
This is the privacy policy for the Android app "CAPod - Companion for AirPods" by Matthias Urhahn (darken).
## Preamble
CAPod respects your privacy.
+12 -2
View File
@@ -1,10 +1,12 @@
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400">
# Companion App for AirPods (CAPod)
[![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases)](https://github.com/d4rken-org/capod/releases/latest)
[![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:
@@ -25,11 +27,15 @@ Currently supported models:
* AirPods 1. Generation
* AirPods 2. Generation
* AirPods 3. Generation
* AirPods 4. Generation
* AirPods 4. Generation with ANC
* AirPods Pro 1. Generation
* AirPods Pro 2. Generation
* AirPods Pro 2. Generation (USB-C)
* AirPods Max
* Power Beats Pro
* Power Beats 3
* Power Beats 4
* Beats Solo 3
* Beats Studio 3
* Beats X
@@ -51,7 +57,6 @@ Currently supported models:
| [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [![](https://img.shields.io/f-droid/v/eu.darken.capod)](https://f-droid.org/en/packages/eu.darken.capod/) |
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [![](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/eu.darken.capod)](https://apt.izzysoft.de/packages/eu.darken.capod/) |
## Support the project
* Buy the CAPod Pro In-App purchase on [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
@@ -67,6 +72,7 @@ Currently supported models:
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
## Thanks to
* The [OpenPods](https://github.com/adolfintel/OpenPods) project,
@@ -75,6 +81,10 @@ 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.
* [@kavishdevar](https://github.com/kavishdevar/librepods) and
his [LibrePods project](https://github.com/kavishdevar/librepods) for sharing a lot of cool stuff.
## License
+1 -1
View File
@@ -1 +1 @@
2.11.0-rc0 21100000
2.17.0-rc0 21700000
+23
View File
@@ -0,0 +1,23 @@
theme: minima
plugins:
- jekyll-relative-links
relative_links:
enabled: true
collections: true
title: "CAPod"
description: "A companion app for AirPods on Android."
author: "by Matthias Urhahn"
include:
- PRIVACY_POLICY.md
exclude:
- buildSrc
- gradle/wrapper
- fastlane
- Gemfile
- Gemfile.lock
- crowdin*
- app
- app-common
- app-wear
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="{{ page.lang | default: site.lang | default: 'en' }}">
{%- include head.html -%}
<body>
<main class="page-content" aria-label="Content">
<div class="wrapper">
{{ content }}
</div>
</main>
{%- include footer.html -%}
</body>
</html>
+2 -1
View File
@@ -1,6 +1,7 @@
plugins {
id("com.android.library")
id("kotlin-android")
id("com.google.devtools.ksp")
id("kotlin-kapt")
id("kotlin-parcelize")
}
@@ -84,7 +85,7 @@ android {
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
addBaseAndroid()
addBaseAndroidUi()
@@ -1,6 +1,6 @@
package eu.darken.capod.common
import java.util.*
import java.util.BitSet
fun Byte.toHex(): String = String.format("%02X", this)
fun UByte.toHex(): String = this.toByte().toHex()
@@ -18,3 +18,12 @@ fun UShort.isBitSet(pos: Int): Boolean = this.toShort().isBitSet(pos)
fun UShort.toBinaryString(): String = Integer.toBinaryString(this.toInt()).padStart(4, '0')
fun UByte.toBinaryString(): String = Integer.toBinaryString(this.toInt()).padStart(8, '0')
fun ByteArray.toHex(separator: String = "-"): String = joinToString(separator) { "%02X".format(it) }
fun String.fromHex(): ByteArray = this
.replace(" ", "")
.replace("-", "")
.also { require(it.length % 2 == 0) { "Not a HEX string, length: ${it.length}" } }
.chunked(2).map { it.toInt(16).toByte() }
.toByteArray()
@@ -1,5 +1,5 @@
package eu.darken.capod.common
object PrivacyPolicy {
const val URL = "https://github.com/d4rken-org/capod/blob/main/PRIVACY_POLICY.md"
const val URL = "https://capod.darken.eu/privacy"
}
@@ -2,10 +2,11 @@ package eu.darken.capod.common
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import javax.inject.Inject
@@ -15,13 +16,13 @@ class WebpageTool @Inject constructor(
) {
fun open(address: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(address)).apply {
val intent = Intent(Intent.ACTION_VIEW, address.toUri()).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (e: Exception) {
log(ERROR) { "Failed to launch" }
log(ERROR) { "Failed to launch: ${e.asLog()}" }
}
}
@@ -9,13 +9,19 @@ import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -132,12 +138,12 @@ class BleScanner @Inject constructor(
ScannerMode.LOW_POWER -> {
setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
setMatchMode(ScanSettings.MATCH_MODE_STICKY)
setNumOfMatches(ScanSettings.MATCH_NUM_FEW_ADVERTISEMENT)
setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
}
ScannerMode.BALANCED -> {
setScanMode(ScanSettings.SCAN_MODE_BALANCED)
setMatchMode(ScanSettings.MATCH_MODE_STICKY)
setNumOfMatches(ScanSettings.MATCH_NUM_FEW_ADVERTISEMENT)
setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
}
ScannerMode.LOW_LATENCY -> {
setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
@@ -0,0 +1,3 @@
package eu.darken.capod.common.bluetooth
typealias BluetoothAddress = String
@@ -7,7 +7,7 @@ data class BluetoothDevice2(
internal val internal: BluetoothDevice,
val seenFirstAt: Instant,
) {
val address: String
val address: BluetoothAddress
get() = internal.address
val name: String?
@@ -3,6 +3,7 @@ package eu.darken.capod.common.bluetooth
import dagger.Reusable
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.fromHex
import java.time.Instant
import javax.inject.Inject
import kotlin.random.Random
@@ -25,7 +26,7 @@ class FakeBleData @Inject constructor(
address = "78:73:AF:B4:85:22",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
).run {
fakeDevices.add(this)
}
@@ -36,7 +37,7 @@ class FakeBleData @Inject constructor(
address = "78:73:FF:B4:85:5E",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
).run {
fakeDevices.add(this)
}
@@ -47,7 +48,7 @@ class FakeBleData @Inject constructor(
address = "4E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 200,
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".fromHex())
).run {
fakeDevices.add(this)
}
@@ -57,7 +58,7 @@ class FakeBleData @Inject constructor(
address = "7E:E5:C7:65:D2:B5",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 300,
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".fromHex())
).run {
fakeDevices.add(this)
}
@@ -67,7 +68,7 @@ class FakeBleData @Inject constructor(
address = "5E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
).run {
fakeDevices.add(this)
}
@@ -78,7 +79,7 @@ class FakeBleData @Inject constructor(
address = "5E:9E:D1:29:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".fromHex())
).run {
fakeDevices.add(this)
}
@@ -89,22 +90,11 @@ class FakeBleData @Inject constructor(
address = "6E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 500,
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
).run {
fakeDevices.add(this)
}
return fakeDevices
}
private fun String.hexToByteArray(): ByteArray {
val trimmed = this
.replace(" ", "")
.replace(">", "")
.replace("<", "")
require(trimmed.length % 2 == 0) { "Not a HEX string" }
return trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
}
@@ -0,0 +1,14 @@
package eu.darken.capod.common.preferences
import android.content.Context
import android.util.AttributeSet
import androidx.preference.SwitchPreferenceCompat
class MaterialSwitchPreference(context: Context, attrs: AttributeSet?) :
SwitchPreferenceCompat(context, attrs) {
init {
// Use material switch
widgetLayoutResource = eu.darken.capod.common.R.layout.preference_material_switch
}
}
@@ -0,0 +1,16 @@
package eu.darken.capod.common.serialization
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@Suppress("unused")
@OptIn(ExperimentalEncodingApi::class)
class ByteArrayAdapter {
@ToJson
fun toJson(obj: ByteArray): String = Base64.encode(obj)
@FromJson
fun fromJson(base64: String): ByteArray? = Base64.decode(base64)
}
@@ -15,6 +15,7 @@ class SerializationModule {
@Singleton
fun moshi(): Moshi = Moshi.Builder()
.add(JavaInstantAdapter())
.add(ByteArrayAdapter())
.build()
}
@@ -5,12 +5,15 @@ import android.content.SharedPreferences
import androidx.preference.PreferenceDataStore
import com.squareup.moshi.Moshi
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.preferences.PreferenceStoreMapper
import eu.darken.capod.common.preferences.Settings
import eu.darken.capod.common.preferences.createFlowPreference
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey
import javax.inject.Inject
import javax.inject.Singleton
@@ -24,14 +27,27 @@ class GeneralSettings @Inject constructor(
override val preferences: SharedPreferences = context.getSharedPreferences("settings_general", Context.MODE_PRIVATE)
val monitorMode = preferences.createFlowPreference("core.monitor.mode", MonitorMode.AUTOMATIC, moshi)
val useExtraMonitorNotification = preferences.createFlowPreference("core.monitor.notification.connected", false)
val scannerMode = preferences.createFlowPreference("core.scanner.mode", ScannerMode.BALANCED, moshi)
val showAll = preferences.createFlowPreference("core.showall.enabled", true)
val minimumSignalQuality = preferences.createFlowPreference("core.signal.minimum", 0.25f)
val minimumSignalQuality = preferences.createFlowPreference("core.signal.minimum", 0.20f)
val mainDeviceAddress = preferences.createFlowPreference<String?>("core.maindevice.address", null)
val mainDeviceAddress = preferences.createFlowPreference<BluetoothAddress?>("core.maindevice.address", null)
val mainDeviceModel = preferences.createFlowPreference("core.maindevice.model", PodDevice.Model.UNKNOWN, moshi)
val mainDeviceIdentityKey = preferences.createFlowPreference<IdentityResolvingKey?>(
"core.maindevice.identitykey",
null,
moshi
)
val mainDeviceEncryptionKey = preferences.createFlowPreference<ProximityEncryptionKey?>(
"core.maindevice.encryptionkey",
null,
moshi
)
val isOffloadedFilteringDisabled = preferences.createFlowPreference(
"core.compat.offloaded.filtering.disabled",
@@ -44,6 +60,7 @@ class GeneralSettings @Inject constructor(
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
monitorMode,
useExtraMonitorNotification,
scannerMode,
showAll,
minimumSignalQuality,
@@ -19,10 +19,18 @@ import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Duration
@@ -36,10 +44,10 @@ class PodMonitor @Inject constructor(
private val bleScanner: BleScanner,
private val podFactory: PodFactory,
private val generalSettings: GeneralSettings,
private val bluetoothManager: BluetoothManager2,
bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings,
private val podDeviceCache: PodDeviceCache,
private val permissionTool: PermissionTool,
permissionTool: PermissionTool,
) {
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
@@ -121,6 +129,7 @@ class PodMonitor @Inject constructor(
log(TAG, WARN) { "Using unfiltered scan mode" }
setOf(ScanFilter.Builder().build())
}
else -> ProximityPairing.getBleScanFilter()
}
@@ -189,8 +198,13 @@ class PodMonitor @Inject constructor(
}
private fun determineMainDevice(pods: List<PodDevice>): PodDevice? {
val mainDeviceModel = generalSettings.mainDeviceModel.value
val irkHit = pods.filterIsInstance<ApplePods>().firstOrNull { it.flags.isIRKMatch }
if (irkHit != null) {
log(TAG) { "Main device determined via IRK: $irkHit" }
return irkHit
}
val mainDeviceModel = generalSettings.mainDeviceModel.value
val presorted = sortPodsToInterest(pods).sortedByDescending {
it.model == mainDeviceModel && it.model != PodDevice.Model.UNKNOWN
}
@@ -6,11 +6,12 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import java.time.Duration
import java.time.Instant
import java.util.*
import java.util.UUID
import kotlin.math.abs
import kotlin.math.max
@@ -20,7 +21,7 @@ interface PodDevice {
val model: Model
val address: String
val address: BluetoothAddress
get() = scanResult.address
val seenLastAt: Instant
@@ -83,6 +84,14 @@ interface PodDevice {
"AirPods (Gen 3)",
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "airpods.gen4") AIRPODS_GEN4(
"AirPods (Gen 4)",
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "airpods.gen4.anc") AIRPODS_GEN4_ANC(
"AirPods (Gen 4 ANC)",
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "airpods.pro") AIRPODS_PRO(
"AirPods Pro",
R.drawable.devic_airpods_pro2_both
@@ -91,10 +100,18 @@ interface PodDevice {
"AirPods Pro 2",
R.drawable.devic_airpods_pro2_both
),
@Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC(
"AirPods Pro 2 USB-C",
R.drawable.devic_airpods_pro2_both
),
@Json(name = "airpods.max") AIRPODS_MAX(
"AirPods Max",
R.drawable.devic_headphones_generic
),
@Json(name = "airpods.max.usbc") AIRPODS_MAX_USBC(
"AirPods Max USB-C",
R.drawable.devic_headphones_generic
),
@Json(name = "beats.flex") BEATS_FLEX(
"Beats Flex"
),
@@ -110,6 +127,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"
),
@@ -5,10 +5,14 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import eu.darken.capod.pods.core.apple.protocol.RPAChecker
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
@@ -18,13 +22,16 @@ import javax.inject.Singleton
class AppleFactory @Inject constructor(
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
private val proximityPairingDecoder: ProximityPairing.Decoder,
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory<out ApplePods>>,
private val proximityMessageDecrypter: ProximityMessage.Decrypter,
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory>,
private val unknownAppleFactory: UnknownAppleDevice.Factory,
private val generalSettings: GeneralSettings,
private val rpaChecker: RPAChecker,
) {
private val lock = Mutex()
private fun getMessage(scanResult: BleScanResult): ProximityPairing.Message? {
private fun getMessage(scanResult: BleScanResult): ProximityMessage? {
val messages = try {
continuityProtocolDecoder.decode(scanResult)
} catch (e: Exception) {
@@ -50,13 +57,39 @@ class AppleFactory @Inject constructor(
}
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
val pm = getMessage(scanResult) ?: return@withLock null
val proximityMessage = getMessage(scanResult) ?: return@withLock null
val isIrkMatch = generalSettings.mainDeviceIdentityKey.value
?.let { rpaChecker.verify(scanResult.address, it) }
?: false
val payload = ProximityPayload(
public = ProximityPayload.Public(
proximityMessage.data.take(9).toUByteArray()
),
private = run {
if (!isIrkMatch) return@run null
if (proximityMessage.data.size != ProximityPairing.PAIRING_MESSAGE_LENGTH) return@run null
val encKey = generalSettings.mainDeviceEncryptionKey.value
if (encKey == null) return@run null
val encrypted = proximityMessage.data.takeLast(16).toUByteArray().toByteArray()
proximityMessageDecrypter.decrypt(encrypted, encKey)?.let {
ProximityPayload.Private(data = it)
}
},
)
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) }
val factory = podFactories.firstOrNull { it.isResponsible(pm) }
return@withLock (factory ?: unknownAppleFactory).create(
scanResult = scanResult,
message = pm,
payload = payload,
flags = ApplePods.Flags(
isIRKMatch = true,
),
)
}
@@ -5,33 +5,58 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.pods.core.apple.airpods.*
import eu.darken.capod.pods.core.apple.beats.*
import eu.darken.capod.pods.core.apple.misc.*
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen1
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen2
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen3
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4Anc
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
import eu.darken.capod.pods.core.apple.airpods.AirPodsMaxUsbc
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2Usbc
import eu.darken.capod.pods.core.apple.beats.BeatsFitPro
import eu.darken.capod.pods.core.apple.beats.BeatsFlex
import eu.darken.capod.pods.core.apple.beats.BeatsSolo3
import eu.darken.capod.pods.core.apple.beats.BeatsStudio3
import eu.darken.capod.pods.core.apple.beats.BeatsX
import eu.darken.capod.pods.core.apple.beats.PowerBeats3
import eu.darken.capod.pods.core.apple.beats.PowerBeats4
import eu.darken.capod.pods.core.apple.beats.PowerBeatsPro
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen1
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen2
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen3
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro2
@InstallIn(SingletonComponent::class)
@Module
abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen4(factory: AirPodsGen4.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen4Anc(factory: AirPodsGen4Anc.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeats4(factory: PowerBeats4.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory
}
@@ -1,48 +1,62 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.toHex
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
interface ApplePods : PodDevice {
val proximityMessage: ProximityPairing.Message
val payload: ProximityPayload
val flags: Flags
// We start counting at the airpods prefix byte
val rawPrefix: UByte
get() = proximityMessage.data[0]
val pubPrefix: UByte
get() = payload.public.data[0]
val rawDeviceModel: UShort
get() = (((proximityMessage.data[1].toInt() and 255) shl 8) or (proximityMessage.data[2].toInt() and 255)).toUShort()
val pubDeviceModel: UShort
get() = (((payload.public.data[1].toInt() and 255) shl 8) or (payload.public.data[2].toInt() and 255)).toUShort()
val rawStatus: UByte
get() = proximityMessage.data[3]
val pubStatus: UByte
get() = payload.public.data[3]
val rawStatusHex: String
get() = rawStatus.toHex()
val pubPodsBattery: UByte
get() = payload.public.data[4]
val rawPodsBattery: UByte
get() = proximityMessage.data[4]
val pubFlags: UShort
get() = payload.public.data[5].upperNibble
val rawPodsBatteryHex: String
get() = rawPodsBattery.toHex()
val pubCaseBattery: UShort
get() = payload.public.data[5].lowerNibble
val rawFlags: UShort
get() = proximityMessage.data[5].upperNibble
val pubCaseLidState: UByte
get() = payload.public.data[6]
val rawCaseBattery: UShort
get() = proximityMessage.data[5].lowerNibble
val pubDeviceColor: UByte
get() = payload.public.data[7]
val rawCaseLidState: UByte
get() = proximityMessage.data[6]
val pubSuffix: UByte
get() = payload.public.data[8]
val rawDeviceColor: UByte
get() = proximityMessage.data[7]
data class BatteryState(
val isCharging: Boolean,
val level: Float,
)
val rawSuffix: UByte
get() = proximityMessage.data[8]
fun ProximityPayload.Private.asBatteryState(pos: Int): BatteryState? {
val raw = data[pos]
val level = (raw and 0x7Fu).toInt() / 100f
if (level < 0f || level > 1.0f) return null
val isCharging = (raw and 0x80u).toInt() != 0
return BatteryState(
isCharging = isCharging,
level = level
)
}
data class Flags(
val isIRKMatch: Boolean,
)
}
@@ -1,108 +1,26 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.collections.median
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Duration
import java.time.Instant
import kotlin.math.max
import eu.darken.capod.pods.core.apple.history.KnownDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
interface ApplePodsFactory {
fun isResponsible(message: ProximityMessage): Boolean
fun create(scanResult: BleScanResult, payload: ProximityPayload, flags: ApplePods.Flags): ApplePods
data class Markings(
val vendor: UByte,
val length: UByte,
val device: UShort,
val podBatteryData: Set<UShort>,
val caseBatteryData: UShort,
val deviceColor: UByte,
data class ModelInfo(
val full: UShort,
val dirty: UByte,
)
private fun ProximityPairing.Message.getApplePodsMarkings(): Markings = Markings(
vendor = ProximityPairing.CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING,
length = ProximityPairing.PAIRING_MESSAGE_LENGTH.toUByte(),
device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
// Make comparison order independent
podBatteryData = setOf(data[4].upperNibble, data[4].lowerNibble),
caseBatteryData = data[5].lowerNibble,
deviceColor = data[7]
fun ProximityMessage.getModelInfo(): ModelInfo = ModelInfo(
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
dirty = data[1]
)
data class KnownDevice(
val id: PodDevice.Id,
val seenFirstAt: Instant,
val seenCounter: Int,
val history: List<ApplePods>,
val lastCaseBattery: Float?,
) {
val lastMessage: ProximityPairing.Message
get() = history.last().proximityMessage
val lastAddress: String
get() = history.last().address
val reliability: Float
get() {
if (history.size < 2) return 0f
val now = Instant.now()
val pingInterval = List(history.dropLast(1).size) { index ->
Duration.between(history[index].seenLastAt, history[index + 1].seenLastAt).toMillis()
}.map { it.toInt() }.median()
val expectedPings: Float = LOOKBACK.toMillis() / pingInterval.toFloat()
val recentPings = history.filter {
Duration.between(it.seenLastAt, now).toMillis() < LOOKBACK.toMillis() + pingInterval
}.size
val reliability: Float = (recentPings / expectedPings)
// log("#####") { "interval=$pingInterval, expectedPerMinute=$expectedPings, count=$recentPings" }
return max(0.0f, reliability).coerceAtMost(1f)
}
fun rssiSmoothed(latest: Int): Int {
val now = Instant.now()
return history
.filter { Duration.between(it.seenLastAt, now) < LOOKBACK }
.map { it.rssi }
.plus(latest)
.median()
}
fun isOlderThan(age: Duration): Boolean {
val now = Instant.now()
return Duration.between(history.last().seenLastAt, now) > age
}
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object {
// 30 seconds at fastest interval
const val MAX_HISTORY = 60
val LOOKBACK = Duration.ofSeconds(30)
}
}
internal val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery
private fun Collection<ApplePods>.determineLatestCaseBattery(): Float? = this
.filterIsInstance<HasCase>()
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
val definitive = setOf(
DualApplePods.LidState.OPEN,
@@ -118,89 +36,4 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
?.caseLidState
?: DualApplePods.LidState.NOT_IN_CASE
}
open fun historyTrimmer(
pods: List<ApplePods>
): List<ApplePods> {
return pods.takeLast(KnownDevice.MAX_HISTORY)
}
internal open fun searchHistory(current: PodType): KnownDevice? {
val scanResult = current.scanResult
val message = current.proximityMessage
knownDevices.values.toList().forEach { knownDevice ->
if (knownDevice.isOlderThan(Duration.ofSeconds(30))) {
log(tag, VERBOSE) { "searchHistory1: Removing stale known device: $knownDevice" }
knownDevices.remove(knownDevice.id)
}
}
knownDevices.values
.filter { it.history.size > KnownDevice.MAX_HISTORY }
.toList()
.forEach {
knownDevices[it.id] = it.copy(history = historyTrimmer(it.history))
}
var recognizedDevice: KnownDevice? = knownDevices.values
.firstOrNull { it.lastAddress == scanResult.address }
?.also { log(tag, VERBOSE) { "searchHistory1: Recovered previous ID via address: $it" } }
if (recognizedDevice == null) {
val currentMarkers = message.getApplePodsMarkings()
recognizedDevice = knownDevices.values
.firstOrNull { it.lastMessage.getApplePodsMarkings() == currentMarkers }
?.also { log(tag) { "searchHistory1: Close match based on similarity: $currentMarkers" } }
}
if (recognizedDevice == null) {
log(tag, WARN) { "searchHistory1: Didn't recognize: $message" }
}
return recognizedDevice
}
fun updateHistory(device: PodType) {
val existing = knownDevices[device.identifier]
knownDevices[device.identifier] = when {
existing != null -> {
val history = existing.history.plus(device)
existing.copy(
seenCounter = existing.seenCounter + 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery
)
}
else -> {
log(tag) { "searchHistory1: Creating new history for $device" }
val history = listOf(device)
KnownDevice(
id = device.identifier,
seenFirstAt = device.seenFirstAt,
seenCounter = 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery()
)
}
}
}
data class ModelInfo(
val full: UShort,
val dirty: UByte,
)
fun ProximityPairing.Message.getModelInfo(): ModelInfo = ModelInfo(
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
dirty = data[1]
)
abstract fun isResponsible(message: ProximityPairing.Message): Boolean
abstract fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
): ApplePods
}
@@ -4,14 +4,18 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.DualPodDevice.Pod
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasDualMicrophone
import eu.darken.capod.pods.core.HasEarDetectionDual
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
HasDualMicrophone, HasAppleColor {
val primaryPod: Pod
get() = when (rawStatus.isBitSet(5)) {
get() = when (pubStatus.isBitSet(5)) {
true -> Pod.LEFT
false -> Pod.RIGHT
}
@@ -21,13 +25,17 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
get() = !pubStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
payload.private?.asBatteryState(if (areValuesFlipped) 2 else 1)?.let {
return it.level
}
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
true -> pubPodsBattery.upperNibble.toInt()
false -> pubPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
@@ -42,9 +50,13 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
override val batteryRightPodPercent: Float?
get() {
payload.private?.asBatteryState(if (areValuesFlipped) 1 else 2)?.let {
return it.level
}
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
true -> pubPodsBattery.lowerNibble.toInt()
false -> pubPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
@@ -58,24 +70,24 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
}
val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
get() = pubStatus.isBitSet(6)
val isOnePodInCase: Boolean
get() = rawStatus.isBitSet(4)
get() = pubStatus.isBitSet(4)
val areBothPodsInCase: Boolean
get() = rawStatus.isBitSet(2)
get() = pubStatus.isBitSet(2)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
true -> pubStatus.isBitSet(3)
false -> pubStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
true -> pubStatus.isBitSet(1)
false -> pubStatus.isBitSet(3)
}
/**
@@ -83,45 +95,63 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
* For the pod that is in the case, this is flipped again though.
*/
override val isLeftPodMicrophone: Boolean
get() = rawStatus.isBitSet(5) xor isThisPodInThecase
get() = pubStatus.isBitSet(5) xor isThisPodInThecase
/**
* The data flip bit is UNset if the right pod is primary.
* For the pod that is in the case, this is flipped again though.
*/
override val isRightPodMicrophone: Boolean
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
get() = !pubStatus.isBitSet(5) xor isThisPodInThecase
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
get() {
payload.private?.asBatteryState(if (areValuesFlipped) 2 else 1)?.let {
return it.isCharging
}
return when (areValuesFlipped) {
false -> pubFlags.isBitSet(0)
true -> pubFlags.isBitSet(1)
}
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
get() {
payload.private?.asBatteryState(if (areValuesFlipped) 1 else 2)?.let {
return it.isCharging
}
return when (areValuesFlipped) {
false -> pubFlags.isBitSet(1)
true -> pubFlags.isBitSet(0)
}
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> null
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
get() {
payload.private?.asBatteryState(3)?.let { return it.level }
return when (val value = pubCaseBattery.toInt()) {
15 -> null
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
get() {
payload.private?.asBatteryState(3)?.let { return it.isCharging }
return pubFlags.isBitSet(2)
}
val caseLidState: LidState
get() {
val rawstate = rawCaseLidState
return LidState.values().firstOrNull { it.rawRange.contains(rawstate.toInt()) } ?: LidState.UNKNOWN
val rawstate = pubCaseLidState
return LidState.entries.firstOrNull { it.rawRange.contains(rawstate.toInt()) } ?: LidState.UNKNOWN
}
/**
@@ -1,68 +0,0 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.PodDevice
abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualApplePods>(tag) {
fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings(
leftPodBattery = batteryLeftPodPercent,
rightPodBattery = batteryRightPodPercent,
microPhoneLeft = isLeftPodMicrophone,
microPhoneRight = isRightPodMicrophone,
chargingLeft = isLeftPodCharging,
chargingRight = isRightPodCharging,
color = rawDeviceColor,
model = model
)
/**
* Split pods, one in case, one not.
*/
data class SplitPodsMarkings(
val leftPodBattery: Float?,
val rightPodBattery: Float?,
val microPhoneLeft: Boolean,
val microPhoneRight: Boolean,
val chargingLeft: Boolean,
val chargingRight: Boolean,
val color: UByte,
val model: PodDevice.Model,
)
private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualApplePods): Collection<KnownDevice> {
val target = device.getCaseMatchMarkings()
return filter { known ->
known.history
.filterIsInstance<DualApplePods>()
.any { it.getCaseMatchMarkings() == target }
}
}
override fun searchHistory(current: DualApplePods): KnownDevice? {
val basicResult = super.searchHistory(current)
val caseIgnored = knownDevices.values.findSplitPodsMatch(current)
log(tag, DEBUG) { "searchHistory2: Case ignored matches(${caseIgnored.size}): $caseIgnored" }
return when (caseIgnored.size) {
0 -> basicResult
1 -> caseIgnored.single()
else -> {
log(tag) { "searchHistory2: More than one result when ignoring case markers." }
val oldest = caseIgnored.maxByOrNull { it.history.size } ?: return null
caseIgnored.minus(oldest).forEach {
log(tag) { "searchHistory2: Removing outlier: $it" }
knownDevices.remove(it.id)
}
oldest
}
}
}
}
@@ -6,8 +6,8 @@ import eu.darken.capod.pods.core.HasPodStyle
interface HasAppleColor : ApplePods, HasPodStyle {
override val podStyle: HasPodStyle.PodStyle
get() = DeviceColor.values()
.firstOrNull { it.raw == rawDeviceColor }
get() = DeviceColor.entries
.firstOrNull { it.raw == pubDeviceColor }
?: DeviceColor.UNKNOWN
enum class DeviceColor(val raw: UByte?) : HasPodStyle.PodStyle {
@@ -10,13 +10,18 @@ import eu.darken.capod.pods.core.SinglePodDevice
interface SingleApplePods : ApplePods, SinglePodDevice, HasAppleColor {
override val batteryHeadsetPercent: Float?
get() = when (val value = rawPodsBattery.lowerNibble.toInt()) {
15 -> null
else -> if (value > 10) {
log { "Headset above 100% battery: $value" }
1.0f
} else {
(value / 10f)
get() {
payload.private?.asBatteryState(1)?.let {
return it.level
}
return when (val value = pubPodsBattery.lowerNibble.toInt()) {
15 -> null
else -> if (value > 10) {
log { "Headset above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
}
@@ -1,3 +0,0 @@
package eu.darken.capod.pods.core.apple
abstract class SingleApplePodsFactory(private val tag: String) : ApplePodsFactory<SingleApplePods>(tag)
@@ -4,19 +4,23 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
data class AirPodsGen1 constructor(
data class AirPodsGen1(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -34,19 +38,28 @@ data class AirPodsGen1 constructor(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsGen1(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = AirPodsGen1(
scanResult = scanResult,
payload = payload,
flags = flags,
)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,19 +4,23 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
data class AirPodsGen2 constructor(
data class AirPodsGen2(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -34,18 +38,28 @@ data class AirPodsGen2 constructor(
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsGen2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = AirPodsGen2(
scanResult = scanResult,
payload = payload,
flags = flags,
)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,19 +4,23 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
data class AirPodsGen3 constructor(
data class AirPodsGen3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -34,18 +38,24 @@ data class AirPodsGen3 constructor(
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsGen3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = AirPodsGen3(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -0,0 +1,80 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
data class AirPodsGen4(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN4
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = AirPodsGen4(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
repo.updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
}
companion object {
private val DEVICE_CODE = 0x1920.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Gen4")
}
}
@@ -0,0 +1,80 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
data class AirPodsGen4Anc(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN4_ANC
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = AirPodsGen4Anc(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
repo.updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
}
companion object {
private val DEVICE_CODE = 0x1B20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Gen4-ANC")
}
}
@@ -7,10 +7,13 @@ import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.HasAppleColor
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -20,7 +23,8 @@ data class AirPodsMax(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasEarDetection, HasChargeDetection, HasAppleColor {
@@ -31,23 +35,34 @@ data class AirPodsMax(
get() = rssiAverage ?: super<SingleApplePods>.rssi
override val isHeadsetBeingCharged: Boolean
get() = rawFlags.isBitSet(0)
override val isBeingWorn: Boolean
get() = rawStatus.isBitSet(5)
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
get() {
payload.private?.asBatteryState(1)?.let {
return it.isCharging
}
return pubFlags.isBitSet(0)
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsMax(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override val isBeingWorn: Boolean
get() = pubStatus.isBitSet(5)
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = AirPodsMax(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -63,7 +78,7 @@ data class AirPodsMax(
}
companion object {
private val DEVICE_CODE_DIRTY = 10.toUByte()
private val DEVICE_CODE = 0x0A20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Max")
}
}
@@ -0,0 +1,84 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.HasAppleColor
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
data class AirPodsMaxUsbc(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasEarDetection, HasChargeDetection, HasAppleColor {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX_USBC
override val rssi: Int
get() = rssiAverage ?: super<SingleApplePods>.rssi
override val isHeadsetBeingCharged: Boolean
get() {
payload.private?.asBatteryState(1)?.let {
return it.isCharging
}
return pubFlags.isBitSet(0)
}
override val isBeingWorn: Boolean
get() = pubStatus.isBitSet(5)
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = AirPodsMaxUsbc(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
repo.updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
reliability = result.reliability,
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
}
companion object {
private val DEVICE_CODE = 0x1F20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Max", "USBC")
}
}
@@ -6,10 +6,13 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -19,7 +22,8 @@ data class AirPodsPro(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -53,18 +57,28 @@ data class AirPodsPro(
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = AirPodsPro(
scanResult = scanResult,
payload = payload,
flags = flags,
)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -79,7 +93,6 @@ data class AirPodsPro(
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
}
companion object {
@@ -6,10 +6,13 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -19,7 +22,8 @@ data class AirPodsPro2(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -53,18 +57,24 @@ data class AirPodsPro2(
override val rssi: Int
get() = rssiAverage ?: super<HasStateDetectionAirPods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsPro2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = AirPodsPro2(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -0,0 +1,99 @@
package eu.darken.capod.pods.core.apple.airpods
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
data class AirPodsPro2Usbc(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO2_USBC
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super<HasStateDetectionAirPods>.rssi
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = AirPodsPro2Usbc(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
repo.updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
}
companion object {
private val DEVICE_CODE = 0x2420.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro2", "USBC")
}
}
@@ -9,7 +9,7 @@ import eu.darken.capod.pods.core.apple.ApplePods
interface HasStateDetectionAirPods : HasStateDetection, ApplePods {
override val state: ConnectionState
get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN
get() = ConnectionState.entries.firstOrNull { pubSuffix == it.raw } ?: ConnectionState.UNKNOWN
enum class ConnectionState(val raw: UByte?, @StringRes val labelRes: Int) : HasStateDetection.State {
DISCONNECTED(0x00, R.string.pods_connection_state_disconnected_label),
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -16,7 +19,8 @@ data class BeatsFitPro(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -34,18 +38,24 @@ data class BeatsFitPro(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = BeatsFitPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = BeatsFitPro(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -16,7 +19,8 @@ data class BeatsFlex(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -26,18 +30,24 @@ data class BeatsFlex(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = BeatsFlex(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = BeatsFlex(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -16,7 +19,8 @@ data class BeatsSolo3(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -25,18 +29,24 @@ data class BeatsSolo3(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = BeatsSolo3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = BeatsSolo3(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -16,25 +19,32 @@ data class BeatsStudio3(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = BeatsStudio3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = BeatsStudio3(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -16,7 +19,8 @@ data class BeatsX(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -26,18 +30,24 @@ data class BeatsX(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = BeatsX(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = BeatsX(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -16,7 +19,8 @@ data class PowerBeats3(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -26,18 +30,24 @@ data class PowerBeats3(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = PowerBeats3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = PowerBeats3(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -0,0 +1,70 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
data class PowerBeats4(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_4
override val rssi: Int
get() = rssiAverage ?: super<SingleApplePods>.rssi
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = PowerBeats4(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
repo.updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
reliability = result.reliability,
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
}
companion object {
private val DEVICE_CODE = 0x0D20.toUShort()
private val TAG = logTag("PodDevice", "Beats", "PowerBeats", "4")
}
}
@@ -4,9 +4,12 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -16,7 +19,8 @@ data class PowerBeatsPro(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -34,18 +38,24 @@ data class PowerBeatsPro(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = PowerBeatsPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = PowerBeatsPro(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -0,0 +1,68 @@
package eu.darken.capod.pods.core.apple.history
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.collections.median
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Duration
import java.time.Instant
import kotlin.math.max
data class KnownDevice(
val id: PodDevice.Id,
val seenFirstAt: Instant,
val seenCounter: Int,
val history: List<ApplePods>,
val lastCaseBattery: Float?,
) {
val lastPayload: ProximityPayload
get() = history.last().payload
val lastAddress: BluetoothAddress
get() = history.last().address
val reliability: Float
get() {
if (history.size < 2) return 0f
val now = Instant.now()
val pingInterval = List(history.dropLast(1).size) { index ->
Duration.between(history[index].seenLastAt, history[index + 1].seenLastAt).toMillis()
}.map { it.toInt() }.median()
val expectedPings: Float = LOOKBACK.toMillis() / pingInterval.toFloat()
val recentPings = history.filter {
Duration.between(it.seenLastAt, now).toMillis() < LOOKBACK.toMillis() + pingInterval
}.size
val reliability: Float = (recentPings / expectedPings)
// log("#####") { "interval=$pingInterval, expectedPerMinute=$expectedPings, count=$recentPings" }
return max(0.0f, reliability).coerceAtMost(1f)
}
fun rssiSmoothed(latest: Int): Int {
val now = Instant.now()
return history
.filter { Duration.between(it.seenLastAt, now) < LOOKBACK }
.map { it.rssi }
.plus(latest)
.median()
}
fun isOlderThan(age: Duration): Boolean {
val now = Instant.now()
return Duration.between(history.last().seenLastAt, now) > age
}
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object {
// 30 seconds at fastest interval
const val MAX_HISTORY = 60
val LOOKBACK = Duration.ofSeconds(30)
}
}
@@ -0,0 +1,178 @@
package eu.darken.capod.pods.core.apple.history
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import eu.darken.capod.pods.core.apple.protocol.RPAChecker
import java.time.Duration
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PodHistoryRepo @Inject constructor(
private val generalSettings: GeneralSettings,
private val rpaChecker: RPAChecker,
) {
private val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
data class Identifier(
val device: UShort,
val podBatteryData: Set<UShort>,
val caseBatteryData: UShort,
val deviceColor: UByte,
)
private fun ProximityPayload.getFuzzyIdentifier(): Identifier = Identifier(
device = (((public.data[1].toInt() and 255) shl 8) or (public.data[2].toInt() and 255)).toUShort(),
// Make comparison order independent
podBatteryData = setOf(public.data[4].upperNibble, public.data[4].lowerNibble),
caseBatteryData = public.data[5].lowerNibble,
deviceColor = public.data[7]
)
fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings(
leftPodBattery = batteryLeftPodPercent,
rightPodBattery = batteryRightPodPercent,
microPhoneLeft = isLeftPodMicrophone,
microPhoneRight = isRightPodMicrophone,
chargingLeft = isLeftPodCharging,
chargingRight = isRightPodCharging,
color = pubDeviceColor,
model = model
)
/**
* Split pods, one in case, one not.
*/
data class SplitPodsMarkings(
val leftPodBattery: Float?,
val rightPodBattery: Float?,
val microPhoneLeft: Boolean,
val microPhoneRight: Boolean,
val chargingLeft: Boolean,
val chargingRight: Boolean,
val color: UByte,
val model: PodDevice.Model,
)
fun search(current: ApplePods): KnownDevice? {
val basicResult = baseSearch(current)
val caseIgnored = if (current is DualApplePods) {
val target = current.getCaseMatchMarkings()
knownDevices.values.filter { known ->
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target }
}
} else {
emptySet()
}
log(TAG, DEBUG) { "search2: Case ignored matches(${caseIgnored.size}): $caseIgnored" }
return when (caseIgnored.size) {
0 -> basicResult
1 -> caseIgnored.single()
else -> {
log(TAG) { "search2: More than one result when ignoring case markers." }
val oldest = caseIgnored.maxByOrNull { it.history.size } ?: return null
caseIgnored.minus(oldest).forEach {
log(TAG) { "search2: Removing outlier: $it" }
knownDevices.remove(it.id)
}
oldest
}
}
}
private fun baseSearch(current: ApplePods): KnownDevice? {
val scanResult = current.scanResult
val payload = current.payload
// Timeout
knownDevices.values.toList()
.filter { it.isOlderThan(Duration.ofSeconds(30)) }
.forEach { knownDevice ->
log(TAG, VERBOSE) { "search1: Removing stale known device: $knownDevice" }
knownDevices.remove(knownDevice.id)
}
// Trim history
knownDevices.values
.filter { it.history.size > KnownDevice.MAX_HISTORY }
.toList()
.forEach { knownDevices[it.id] = it.copy(history = it.history.takeLast(KnownDevice.MAX_HISTORY)) }
var recognizedDevice: KnownDevice? = knownDevices.values
.firstOrNull { it.lastAddress == scanResult.address }
?.also { log(TAG, VERBOSE) { "search1: Recovered previous ID via address: $it" } }
if (recognizedDevice == null) {
generalSettings.mainDeviceIdentityKey.value?.let { key ->
if (!rpaChecker.verify(current.address, key)) {
log(TAG, VERBOSE) { "search1: Current device does not match IRK" }
return@let
}
recognizedDevice = knownDevices.values
.firstOrNull { rpaChecker.verify(it.lastAddress, key) }
.also { log(TAG, VERBOSE) { "search1: Recovered previous ID via IRK: $it" } }
}
}
if (recognizedDevice == null) {
val currentMarkers = payload.getFuzzyIdentifier()
recognizedDevice = knownDevices.values
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers }
?.also { log(TAG) { "search1: Close match based on similarity: $currentMarkers" } }
}
if (recognizedDevice == null) log(TAG, WARN) { "search1: Didn't recognize: $current" }
return recognizedDevice
}
fun updateHistory(device: ApplePods) {
val existing = knownDevices[device.identifier]
fun Collection<ApplePods>.determineLatestCaseBattery(): Float? = this
.filterIsInstance<HasCase>()
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
knownDevices[device.identifier] = if (existing != null) {
val history = existing.history.plus(device)
existing.copy(
seenCounter = existing.seenCounter + 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery
)
} else {
log(TAG) { "Creating new history for $device" }
val history = listOf(device)
KnownDevice(
id = device.identifier,
seenFirstAt = device.seenFirstAt,
seenCounter = 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery()
)
}
}
companion object {
private val TAG = logTag("Pod", "Apple", "Factory", "History")
}
}
@@ -1,15 +1,17 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -18,118 +20,46 @@ import javax.inject.Inject
* Similar data structure but a lot of placeholder values or hardcoded values
* Marketed as TWS i99999
*/
data class FakeAirPodsGen1 constructor(
data class FakeAirPodsGen1(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
) : DualApplePods, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN1
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
/**
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen1>(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = FakeAirPodsGen1(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = FakeAirPodsGen1(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -1,133 +1,63 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
/**
* Similar data structure but a lot of placeholder values or hardcoded values
*/
data class FakeAirPodsGen2 constructor(
data class FakeAirPodsGen2(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
) : DualApplePods, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN2
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
/**
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
get() = rssiAverage ?: super<DualApplePods>.rssi
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen2>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = FakeAirPodsGen2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = FakeAirPodsGen2(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -1,15 +1,17 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -18,118 +20,46 @@ import javax.inject.Inject
* Shorter data structure.
* https://discord.com/channels/548521543039189022/927235844127993866/1054404630118924308
*/
data class FakeAirPodsGen3 constructor(
data class FakeAirPodsGen3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
) : DualApplePods, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN3
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
/**
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
get() = rssiAverage ?: super<DualApplePods>.rssi
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen3>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = FakeAirPodsGen3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = FakeAirPodsGen3(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -3,15 +3,17 @@ package eu.darken.capod.pods.core.apple.misc
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -20,17 +22,18 @@ import javax.inject.Inject
* Shorter data structure.
* https://discord.com/channels/548521543039189022/927235844127993866/962068944196358234
*/
data class FakeAirPodsPro constructor(
data class FakeAirPodsPro(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasChargeDetectionDual, HasEarDetectionDual, HasCase {
) : DualApplePods, HasChargeDetectionDual, HasEarDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO
@@ -51,103 +54,30 @@ data class FakeAirPodsPro constructor(
get() = R.drawable.devic_airpods_pro2_right
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
/**
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
get() = rssiAverage ?: super<DualApplePods>.rssi
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsPro>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = FakeAirPodsPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = FakeAirPodsPro(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -3,16 +3,19 @@ package eu.darken.capod.pods.core.apple.misc
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -20,18 +23,19 @@ import javax.inject.Inject
* AirPods Pro2 clone
* https://discord.com/channels/548521543039189022/927235844127993866/1060911213539774504
*/
data class FakeAirPodsPro2 constructor(
data class FakeAirPodsPro2(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null,
) : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase {
private val cachedCaseState: LidState? = null,
) : DualApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO2
@@ -52,99 +56,32 @@ data class FakeAirPodsPro2 constructor(
get() = R.drawable.devic_airpods_pro2_right
override val rssi: Int
get() = rssiAverage ?: scanResult.rssi
private val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
get() = rssiAverage ?: super<DualApplePods>.rssi
override val caseLidState: LidState
get() = cachedCaseState ?: super.caseLidState
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsPro2>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = FakeAirPodsPro2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = FakeAirPodsPro2(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -7,7 +7,9 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -17,7 +19,8 @@ data class UnknownAppleDevice(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val flags: ApplePods.Flags,
override val reliability: Float = 0f,
private val rssiAverage: Int? = null,
) : ApplePods {
@@ -29,18 +32,21 @@ data class UnknownAppleDevice(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : ApplePodsFactory<ApplePods>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = true
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = true
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
payload: ProximityPayload,
flags: ApplePods.Flags,
): ApplePods {
var basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
var basic = UnknownAppleDevice(scanResult = scanResult, payload = payload, flags = flags)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -0,0 +1,3 @@
package eu.darken.capod.pods.core.apple.protocol
typealias IdentityResolvingKey = ByteArray
@@ -0,0 +1,3 @@
package eu.darken.capod.pods.core.apple.protocol
typealias ProximityEncryptionKey = ByteArray
@@ -0,0 +1,58 @@
package eu.darken.capod.pods.core.apple.protocol
import android.R.id.message
import android.annotation.SuppressLint
import dagger.Reusable
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import okio.ByteString.Companion.toByteString
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
data class ProximityMessage(
val type: UByte,
val length: Int,
val data: UByteArray
) {
override fun toString(): String {
val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) }
return "ProximityPairing.Message(type=$type, length=$length, data=$dataHex)"
}
@Reusable
class Decrypter @Inject constructor() {
@SuppressLint("GetInstance")
fun decrypt(data: ByteArray, key: ProximityEncryptionKey): UByteArray? {
val decryptedData = try {
val cipher = Cipher.getInstance("AES/ECB/NoPadding").apply {
val secretKey = SecretKeySpec(key, "AES")
init(Cipher.DECRYPT_MODE, secretKey)
}
cipher.doFinal(data.copyOfRange(data.size - 16, data.size))
} catch (e: Exception) {
log(
TAG,
Logging.Priority.ERROR
) { "Failed to decrypt $message with ${key.toByteString()}\n${e.asLog()}" }
null
}
log(
TAG,
Logging.Priority.VERBOSE
) { "Decrypted $message with ${key.toByteString()} to ${decryptedData?.toByteString()}" }
if (decryptedData == null || decryptedData.size != 16) return null
return decryptedData.toUByteArray()
}
companion object {
private val TAG = logTag("Pod", "Factory", "Decrypter")
}
}
}
@@ -8,27 +8,15 @@ import javax.inject.Inject
object ProximityPairing {
data class Message(
val type: UByte,
val length: Int,
val data: UByteArray
) {
override fun toString(): String {
val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) }
return "ProximityPairing.Message(type=$type, length=$length, data=$dataHex)"
}
}
@Reusable
class Decoder @Inject constructor() {
fun decode(message: ContinuityProtocol.Message): Message? {
fun decode(message: ContinuityProtocol.Message): ProximityMessage? {
if (message.type != CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING) {
log { "Not a proximity pairing message: $this" }
return null
}
return Message(
return ProximityMessage(
type = message.type,
length = message.length,
data = message.data
@@ -0,0 +1,15 @@
package eu.darken.capod.pods.core.apple.protocol
data class ProximityPayload(
val public: Public,
val private: Private?,
) {
data class Public(
val data: UByteArray,
)
data class Private(
val data: UByteArray,
)
}
@@ -0,0 +1,52 @@
package eu.darken.capod.pods.core.apple.protocol
import android.annotation.SuppressLint
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import okio.ByteString.Companion.toByteString
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
class RPAChecker @Inject constructor() {
// Resolvable-Private-Address
fun verify(address: BluetoothAddress, irk: IdentityResolvingKey): Boolean = try {
val rpa = address.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
val prand = rpa.copyOfRange(3, 6)
val hash = rpa.copyOfRange(0, 3)
val computedHash = ah(irk, prand)
hash.contentEquals(computedHash)
} catch (e: Exception) {
log(
TAG,
Logging.Priority.ERROR
) { "Failed to verify RPA\naddress=${address}\nIRK=${irk.toByteString()}\n${e.asLog()}" }
false
}
// E function (Encryption function):
@SuppressLint("GetInstance")
private fun e(key: ByteArray, data: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/ECB/NoPadding").apply {
val secretKey = SecretKeySpec(key.reversedArray(), "AES")
init(Cipher.ENCRYPT_MODE, secretKey)
}
return cipher.doFinal(data.reversedArray()).reversedArray()
}
// AH function (Address Hashing function):
private fun ah(k: ByteArray, r: ByteArray): ByteArray {
val rPadded = ByteArray(16).apply {
r.copyInto(this, 0, 0, 3)
}
return e(k, rPadded).copyOfRange(0, 3)
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor", "RPAChecker")
}
}
@@ -1,5 +1,6 @@
package eu.darken.capod.reaction.core.popup
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
@@ -51,8 +52,8 @@ class PopUpReaction @Inject constructor(
return@mapNotNull null
}
log(TAG, VERBOSE) {
val prev = previous?.rawCaseLidState?.let { String.format("%02X", it.toByte()) }
val cur = current.rawCaseLidState.let { String.format("%02X", it.toByte()) }
val prev = previous?.pubCaseLidState?.let { String.format("%02X", it.toByte()) }
val cur = current.pubCaseLidState.let { String.format("%02X", it.toByte()) }
"previous=$prev (${previous?.caseLidState}), current=$cur (${current.caseLidState})"
}
log(TAG, VERBOSE) { "previous-id=${previous?.identifier}, current-id=${current.identifier}" }
@@ -109,7 +110,7 @@ class PopUpReaction @Inject constructor(
else -> null
}
private val connectionCoolDowns = mutableMapOf<String, Instant>()
private val connectionCoolDowns = mutableMapOf<BluetoothAddress, Instant>()
private fun monitorConnection(): Flow<Event> = reactionSettings.showPopUpOnConnection.flow
.flatMapLatest { isEnabled ->
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M23 4.5C23 6.43 21.43 8 19.5 8S16 6.43 16 4.5 17.57 1 19.5 1 23 2.57 23 4.5M19.5 10C16.47 10 14 7.53 14 4.5C14 4 14.08 3.5 14.21 3H5C3.89 3 3 3.89 3 5V19C3 20.11 3.9 21 5 21H19C20.11 21 21 20.11 21 19V9.79C20.5 9.92 20 10 19.5 10Z" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.materialswitch.MaterialSwitch xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/switchWidget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@null"
android:clickable="false"
android:focusable="false" />
@@ -1,8 +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="app_name"></string>
<string name="app_name_pro">Cc</string>
<string name="app_name_foss"></string>
<string name="general_value_not_available_label">غير متوفر</string>
<string name="general_error_label">خطأ</string>
<string name="general_grant_permission_action">منح الإذن</string>
@@ -55,7 +55,10 @@
<string name="pods_microphone_label">ميكروفون</string>
<string name="pods_yours">الخاصة بك</string>
<string name="headset_being_worn_label">يتم ارتداؤها</string>
<string name="headset_not_being_worn_label"></string>
<string name="pods_case_unknown_state">حالة غير معروفة</string>
<string name="last_seen_x">آخر ظهور: %s</string>
<string name="first_seen_x">أول ظهور:%s</string>
<string name="permission_post_notifications_label"></string>
<string name="permission_post_notifications_description">"اسمح لـلتطبيق بإظهار الإشعارات المتعلقة بسماعات الأذن الخاصة بك، على سبيل المثال. حالتهم الحالية خلال الاتصال."</string>
</resources>
@@ -1,8 +1,5 @@
<?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>
@@ -14,7 +11,6 @@
<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 для злучэння са спалучанымі прыладамі.</string>
<string name="permission_access_fine_location_label">Доступ да дакладнага месцазнаходжання</string>
<string name="permission_access_fine_location_description">CAPod выкарыстоўвае дазвол \"Дакладнае месцазнаходжанне\" для атрымання даных Bluetooth Low Energy. Вашы навушнікі з дапамогай тэхналогіі Bluetooth Low Energy перадаюць інфармацыю пра свой стан. Гэта праграма НЕ будзе карыстацца данымі Bluetooth для вызначэння вашага месцазнаходжання.</string>
@@ -58,4 +54,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>
@@ -55,6 +55,7 @@
<string name="pods_microphone_label">Micròfon</string>
<string name="pods_yours">Vostres</string>
<string name="headset_being_worn_label">En ús</string>
<string name="headset_not_being_worn_label">No s\'utilitzen</string>
<string name="pods_case_unknown_state">Estat desconegut</string>
<string name="last_seen_x">Últim cop vist: %s</string>
<string name="first_seen_x">Primera vegada vist: %s</string>
@@ -55,6 +55,7 @@
<string name="pods_microphone_label">Mikrofon</string>
<string name="pods_yours">Vaše</string>
<string name="headset_being_worn_label">Používá se</string>
<string name="headset_not_being_worn_label">Nepoužíváno</string>
<string name="pods_case_unknown_state">Neznámý stav</string>
<string name="last_seen_x">Naposledy spatřeno: %s</string>
<string name="first_seen_x">Poprvé spatřeno: %s</string>
@@ -32,7 +32,7 @@
<string name="settings_monitor_mode_automatic_label">Wenn das Gerät verbunden ist</string>
<string name="settings_monitor_mode_always_label">Stets</string>
<string name="settings_reaction_autoconnect_whenseen_label">Wenn gesehen</string>
<string name="settings_reaction_autoconnect_caseopen_label">Fall ist offen</string>
<string name="settings_reaction_autoconnect_caseopen_label">Ladecase ist offen</string>
<string name="settings_reaction_autoconnect_inear_label">Im Ohr</string>
<string name="pods_dual_left_label">Linker Pod</string>
<string name="pods_dual_right_label">Rechter Pod</string>
@@ -55,6 +55,7 @@
<string name="pods_microphone_label">Mikrofon</string>
<string name="pods_yours">Dein</string>
<string name="headset_being_worn_label">Wird getragen</string>
<string name="headset_not_being_worn_label">Wird nicht getragen</string>
<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>
@@ -3,7 +3,7 @@
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">Δε διατίθεται</string>
<string name="general_value_not_available_label">Μ/Δ</string>
<string name="general_error_label">Σφάλμα</string>
<string name="general_grant_permission_action">Παραχώρηση άδειας</string>
<string name="overview_nomaindevice_label">Καμία κύρια συσκευή</string>
@@ -23,7 +23,7 @@
<string name="permission_ignore_battery_optimizations_label">Απενεργοποίηση βελτιστοποιήσεων μπαταρίας</string>
<string name="permission_ignore_battery_optimizations_description">Οι βελτιστοποιήσεις μπαταρίας εμποδίζουν αυτήν την εφαρμογή να λαμβάνει αξιόπιστα δεδομένα Bluetooth ενώ η εφαρμογή βρίσκεται στο παρασκήνιο.</string>
<string name="permission_required_title">Η ακόλουθη άδεια είναι απαραίτητη:</string>
<string name="permission_system_alert_window_label">Παράθυρο ειδοποίησης συστήματος</string>
<string name="permission_system_alert_window_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>
@@ -46,7 +46,7 @@
<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_raw_data_label">Ακατέργαστα δεδομένα</string>
<string name="pods_unknown_label">Άγνωστη συσκευή</string>
<string name="pods_unknown_contact_dev">Αυτή είναι μια άγνωστη συσκευή, αλλά χρησιμοποιεί παρόμοια μορφή μηνυμάτων. Ας προσθέσουμε υποστήριξη για αυτήν, επικοινωνήστε μαζί μου :)</string>
<string name="pods_none_label_short">Καμία συσκευή</string>
@@ -55,6 +55,7 @@
<string name="pods_microphone_label">Μικρόφωνο</string>
<string name="pods_yours">Δικό σας</string>
<string name="headset_being_worn_label">Φοριέται</string>
<string name="headset_not_being_worn_label">Δε φοριέται</string>
<string name="pods_case_unknown_state">Άγνωστη κατάσταση</string>
<string name="last_seen_x">Τελευταία εμφάνιση: %s</string>
<string name="first_seen_x">Πρώτη εμφάνιση: %s</string>
@@ -1,10 +1,6 @@
<?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">N/D</string>
<string name="general_error_label">Error</string>
<string name="general_grant_permission_action">Conceder permiso</string>
<string name="overview_nomaindevice_label">Ningún dispositivo principal</string>
</resources>
@@ -1,6 +1,5 @@
<?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="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>
+19 -17
View File
@@ -1,30 +1,31 @@
<?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="app_name">Hello, I am Izan</string>
<string name="app_name_pro">AirPods Pro
Popu</string>
<string name="app_name_foss">PodCAPod FOSS</string>
<string name="general_value_not_available_label">No disponible</string>
<string name="general_error_label">Fallo</string>
<string name="general_grant_permission_action">Conceder el permiso</string>
<string name="overview_nomaindevice_label">Sin dispositivo principal</string>
<string name="overview_nomaindevice_description">Es poco probable que todos los dispositivos detectados sean tuyos. Enciende y conecta tu dispositivo o ajusta la configuración.</string>
<string name="general_error_label">Error</string>
<string name="general_grant_permission_action">Otorgar el permiso</string>
<string name="overview_nomaindevice_label">Ningún dispositivo principal</string>
<string name="overview_nomaindevice_description">Es poco probable que todos los dispositivos detectados sean suyos. Enciende y conecta tu dispositivo o ajusta la configuración.</string>
<string name="overview_bluetooth_disabled_label">Bluetooth desactivado</string>
<string name="overview_bluetooth_disabled_description">El Bluetooth está desactivado, actívalo ;)</string>
<string name="overview_bluetooth_disabled_description">Bluetooth está desactivado, actívalo ;)</string>
<string name="permission_bluetooth_connect_label">Conexión por Bluetooth</string>
<string name="permission_bluetooth_connect_description">Esta aplicación requiere el permiso \"Conexión por Bluetooth\" para interactuar con los dispositivos emparejados e iniciar las conexiones.</string>
<string name="permission_bluetooth_connect_description">Esta aplicación necesita el permiso \"Conexión Bluetooth\" para interactuar con los dispositivos emparejados e iniciar conexiones.</string>
<string name="permission_bluetooth_scan_label">El Bluetooth está escaneando</string>
<string name="permission_bluetooth_scan_description">El permiso \"Escaneo del Bluetooth\" permite que esta aplicación descubra y reciba datos de Bluetooth de dispositivos cercanos, como tus AirPods.</string>
<string name="permission_bluetooth_scan_description">El permiso \"Escaneo por Bluetooth\" permite que esta aplicación detecte y reciba datos de Bluetooth de dispositivos cercanos, como los AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Esta aplicación requiere el permiso \"Bluetooth\" para conectarse a dispositivos bluetooth emparejados.</string>
<string name="permission_bluetooth_description">Esta aplicación requiere el permiso \"Bluetooth\" para conectarse a dispositivos emparejados de bluetooth.</string>
<string name="permission_access_fine_location_label">Acceso preciso a tu ubicación</string>
<string name="permission_access_fine_location_description">CAPod utiliza el permiso \"localización precisa\" para recibir datos Bluetooth Low Energy. Tus auriculares utilizan la tecnología Bluetooth Low Energy para transmitir tu estado. Esta aplicación NO utilizará los datos Bluetooth para determinar tu ubicación.</string>
<string name="permission_background_location_label">Ubicación en segundo plano</string>
<string name="permission_background_location_description">CAPod utiliza el \"la ubicación en segundo plano\" para activar funciones como \"Mostrar ventana emergente\" y \"Conexión automática\" mientras la aplicación está cerrada. El acceso a la localización en segundo plano permite a la aplicación recibir datos de Bluetooth Low Energy mientras está en segundo plano. Esta aplicación NO utilizará los datos Bluetooth para determinar tu ubicación.</string>
<string name="permission_access_fine_location_description">CAPod utiliza el permiso \"ubicación fina\" para recibir datos de Bluetooth de baja energía. Tus auriculares utilizan la tecnología Bluetooth de baja energía para transmitir su estado. Esta aplicación NO utilizará datos Bluetooth para determinar tu ubicación.</string>
<string name="permission_background_location_label">Acceso a la ubicación en segundo plano</string>
<string name="permission_background_location_description">CAPod utiliza \"acceso a la ubicación en segundo plano\" para habilitar características como \"Mostrar popup\" y \"AutoConnect\" mientras la aplicación está cerrada. El acceso a la ubicación en segundo plano permite que la aplicación reciba datos de Bluetooth de baja energía mientras está en segundo plano. Esta aplicación NO utilizará datos de Bluetooth para determinar su ubicación.</string>
<string name="permission_ignore_battery_optimizations_label">Desactivar las optimizaciones de la batería</string>
<string name="permission_ignore_battery_optimizations_description">Las optimizaciones de la batería impiden que esta aplicación reciba datos Bluetooth de forma fiable mientras está en segundo plano.</string>
<string name="permission_ignore_battery_optimizations_description">Las optimizaciones de la batería impiden que esta aplicación reciba datos Bluetooth de forma fiable mientras la aplicación está en segundo plano.</string>
<string name="permission_required_title">Se requiere el siguiente permiso:</string>
<string name="permission_system_alert_window_label">Ventana de alerta del sistema</string>
<string name="permission_system_alert_window_description">Permite a CAPod dibujar sobre otras aplicaciones para hacer posible la función \"Mostrar una ventana emergente\".</string>
<string name="permission_system_alert_window_description">Permitir que CAPod funcione sobre otras aplicaciones para hacer posible la función \"Mostrar una ventana emergente\".</string>
<string name="settings_scanner_mode_lowpower_label">Bajo consumo</string>
<string name="settings_scanner_mode_balanced_label">Equilibrado</string>
<string name="settings_scanner_mode_lowlatency_label">Baja latencia</string>
@@ -53,8 +54,9 @@
<string name="pods_charging_label">Cargando</string>
<string name="pods_inear_label">En el oído</string>
<string name="pods_microphone_label">Micrófono</string>
<string name="pods_yours">Tuyos</string>
<string name="pods_yours">Tus</string>
<string name="headset_being_worn_label">Utilizándose</string>
<string name="headset_not_being_worn_label">No se usan</string>
<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>
@@ -2,10 +2,63 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">Tasuline CAPod</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="app_name_foss">CAPod tasuta ja vabavaraline</string>
<string name="general_value_not_available_label">Pole saadaval</string>
<string name="general_error_label">Viga</string>
<string name="general_grant_permission_action">Anna õigus</string>
<string name="overview_nomaindevice_label">Puudub peamine seade</string>
<string name="overview_nomaindevice_description">Kõik tuvastatud seadmeid ei kuulu tõenäoliselt teile. Lülitage oma seade sisse ja ühendage või muutke seadeid.</string>
<string name="overview_bluetooth_disabled_label">Bluetooth on välja lülitatud</string>
<string name="overview_bluetooth_disabled_description">Bluetooth on keelatud, lubage see ;)</string>
<string name="permission_bluetooth_connect_label">Bluetooth ühendus</string>
<string name="permission_bluetooth_connect_description">See rakendus nõuab luba kasutada Bluetooh ühendust, et suhelda seotud seadmetega ja käivitada ühendamist.</string>
<string name="permission_bluetooth_scan_label">Bluetoothi skannimine</string>
<string name="permission_bluetooth_scan_description">Bluetoothi skannimise luba võimaldab rakendusel avastada ja vastu võtta Bluetooth andmeid lähedal asuvatest seadmetest, nagu AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">See rakendus nõuab Bluetooth luba, et ühenduda seotud Bluetooth seadmetega.</string>
<string name="permission_access_fine_location_label">Luba täpne asukoht</string>
<string name="permission_access_fine_location_description">CAPod saab täpse asukoha loaga madalama energiatarbimisega andmeid. Kõrvaklapid edastavad selle tehnoloogiaga oma olekut. See rakendus ei tuvasta Bluetooth andmetega teie asukohta.</string>
<string name="permission_background_location_label">Juurdepääs asukohale taustal</string>
<string name="permission_background_location_description">CAPod võimaldab sellega hüpikakende näitamist ja automaatset ühendumist, kui rakendus on suletud ning saada Bluetooth madala energiatarbimisega andmeid, kui rakendus töötab taustal. See rakendus ei tuvasta Bluetooth andmetega teie asukohta.</string>
<string name="permission_ignore_battery_optimizations_label">Keela aku optimeerimine</string>
<string name="permission_ignore_battery_optimizations_description">Aku optimeerimine takistab rakendusel Bluetooth andmete saamist taustal töötades.</string>
<string name="permission_required_title">Vaja on järgmist luba:</string>
<string name="permission_system_alert_window_label">Süsteemi teate aken</string>
<string name="permission_system_alert_window_description">Luba CAPodil toimetada teiste rakenduste kohal, et näha hüpikaknaid.</string>
<string name="settings_scanner_mode_lowpower_label">Madal võimsus</string>
<string name="settings_scanner_mode_balanced_label">Tasakaalustatud</string>
<string name="settings_scanner_mode_lowlatency_label">Madal latents</string>
<string name="settings_monitor_mode_manual_label">Kui rakendus on avatud</string>
<string name="settings_monitor_mode_automatic_label">Kui seade on ühendatud</string>
<string name="settings_monitor_mode_always_label">Alati</string>
<string name="settings_reaction_autoconnect_whenseen_label">Kui nähtaval</string>
<string name="settings_reaction_autoconnect_caseopen_label">Kaas on avatud</string>
<string name="settings_reaction_autoconnect_inear_label">Kõrvas</string>
<string name="pods_dual_left_label">Vasak klapp</string>
<string name="pods_dual_right_label">Parem klapp</string>
<string name="pods_case_label">Kaas</string>
<string name="pods_case_status_open_label">Avatud</string>
<string name="pods_case_status_closed_label">Suletud</string>
<string name="pods_connection_state_disconnected_label">Puudub ühendus seadmega</string>
<string name="pods_connection_state_idle_label">Ühendatud seadmega, ent ooterežiimil</string>
<string name="pods_connection_state_music_label">Muusikarežiimis</string>
<string name="pods_connection_state_call_label">Kõnerežiimis</string>
<string name="pods_connection_state_ringing_label">Heliseb</string>
<string name="pods_connection_state_hanging_up_label">Kõne lõpetamine</string>
<string name="pods_connection_state_unknown_label">Tundmatu ühenduse olek</string>
<string name="pods_unknown_raw_data_label">Algandmed</string>
<string name="pods_unknown_label">Tundmatu seade</string>
<string name="pods_unknown_contact_dev">See on tundmatu seade, mis kasutab sarnast teatevormingut. Las ma muudan selle ühilduvaks, selleks võta minuga ühendust :)</string>
<string name="pods_none_label_short">Puudub seade</string>
<string name="pods_charging_label">Laeb</string>
<string name="pods_inear_label">Kõrvas</string>
<string name="pods_microphone_label">Mikrofon</string>
<string name="pods_yours">Sinu</string>
<string name="headset_being_worn_label">Kasutusel</string>
<string name="headset_not_being_worn_label">Ei kasuta</string>
<string name="pods_case_unknown_state">Tundmatu olek</string>
<string name="last_seen_x">Viimati kasutusel: %s</string>
<string name="first_seen_x">Esimesena kasutusel: %s</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>
@@ -3,7 +3,7 @@
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS (libre)</string>
<string name="general_value_not_available_label">Non disponible</string>
<string name="general_value_not_available_label">N.D.</string>
<string name="general_error_label">Erreur</string>
<string name="general_grant_permission_action">Accorder lautorisation</string>
<string name="overview_nomaindevice_label">Aucun appareil principal</string>
@@ -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 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_background_location_description">CAPod utilise « laccès à la position en arrière-plan » afin de permettre des fonctions telles qu’« Afficher une fenêtre contextuelle » 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 batterie</string>
<string name="permission_ignore_battery_optimizations_description">Les optimisations de la 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 fenêtre surgissante » 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 contextuelle » 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>
@@ -55,6 +55,7 @@
<string name="pods_microphone_label">Microphone</string>
<string name="pods_yours">Les vôtres</string>
<string name="headset_being_worn_label">Porté</string>
<string name="headset_not_being_worn_label">Non porté</string>
<string name="pods_case_unknown_state">État inconnu</string>
<string name="last_seen_x">Vu pour la dernière fois : %s</string>
<string name="first_seen_x">Vu pour la première fois : %s</string>
@@ -2,8 +2,6 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">कैपोड</string>
<string name="app_name_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">त्रुटि</string>
<string name="general_grant_permission_action">अनुदान अनुमति</string>
<string name="overview_nomaindevice_label">कोई प्राथमिक उपकरण नहीं</string>
+37 -30
View File
@@ -1,57 +1,64 @@
<?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">Nincs_adat</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">Nincs adat</string>
<string name="general_error_label">Hiba</string>
<string name="general_grant_permission_action">Hozzáférés megadva</string>
<string name="general_grant_permission_action">Engedély megadása</string>
<string name="overview_nomaindevice_label">Nincs elsődleges eszköz</string>
<string name="overview_nomaindevice_description">Valószínűleg nem minden észlelt eszköz az Öné. Kapcsolja be és csatlakoztassa a készüléket, vagy módosítsa a beállításokat.</string>
<string name="overview_bluetooth_disabled_label">A Bluetooth kikapcsolva</string>
<string name="overview_bluetooth_disabled_description">Bluetooth kikapcsolva, engedélyezd ;)</string>
<string name="permission_bluetooth_connect_label">Bluetooth_kapcsolat</string>
<string name="permission_bluetooth_connect_description">Ehhez az alkalmazáshoz a \"Bluetooth kapcsolat\" engedély szükséges a párosított eszközökkel való interakcióhoz és a kapcsolatok kezdeményezéséhez.</string>
<string name="permission_bluetooth_scan_label">Bluetooth_keresés</string>
<string name="permission_bluetooth_scan_description">A „Bluetooth-keresés” engedély lehetővé teszi az alkalmazás számára, hogy Bluetooth-adatokat keressen és fogadjon a közeli eszközökről, például az Ön AirPodjairól.</string>
<string name="overview_bluetooth_disabled_label">A Bluetooth ki van kapcsolva</string>
<string name="overview_bluetooth_disabled_description">Bluetooth ki van kapcsolva, engedélyezze ;)</string>
<string name="permission_bluetooth_connect_label">Bluetooth csatlakozás</string>
<string name="permission_bluetooth_connect_description">Ehhez az alkalmazáshoz a \"Közeli eszközök\" engedély szükséges a párosított eszközökkel való interakcióhoz és a kapcsolatok kezdeményezéséhez.</string>
<string name="permission_bluetooth_scan_label">Bluetooth keresés</string>
<string name="permission_bluetooth_scan_description">A „Bluetooth-keresés” engedély lehetővé teszi az alkalmazás számára, hogy Bluetooth-adatokat keressen és fogadjon a közeli eszközökről, például az Ön AirPods-jairól.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Ehhez az alkalmazáshoz „Bluetooth” engedély szükséges a párosított Bluetooth-eszközökhöz való csatlakozáshoz.</string>
<string name="permission_access_fine_location_label">Lokalizálás engedélyezése</string>
<string name="permission_access_fine_location_description">A CAPod a „megfelelő hely” engedélyt használja a Bluetooth alacsony töltés adatok fogadására. Fejhallgatója Bluetooth alacsony töltés technológiát használ az állapotának közvetítésére. Ez az alkalmazás NEM használ Bluetooth-adatokat az Ön tartózkodási helyének meghatározásához.</string>
<string name="permission_background_location_label">Háttér helyhozzáférés</string>
<string name="permission_background_location_description">\"Automatikus_csatlakozás\".</string>
<string name="permission_ignore_battery_optimizations_label">Az akkumulátor optimalizálás letiltása</string>
<string name="permission_access_fine_location_description">A CAPod a „pontos hely” engedélyt használja a Bluetooth Low Energy adatok fogadására. Fülhallgatója Bluetooth Low Energy technológiát használ az állapotának közvetítésére. Ez az alkalmazás NEM használ Bluetooth adatokat az Ön tartózkodási helyének meghatározásához.</string>
<string name="permission_background_location_label">Helymeghatározás a háttérben</string>
<string name="permission_background_location_description">A CAPod a „háttérben történő helymeghatározást” használja az olyan funkciók engedélyezéséhez, mint az „Előugró ablak” és az „Automatikus csatlakozás”, miközben az alkalmazás zárva van. A háttérben történő helymeghatározás lehetővé teszi, hogy az alkalmazás Bluetooth Low Energy adatokat fogadjon miközben be van zárva. Ez az alkalmazás NEM használ Bluetooth adatokat az Ön tartózkodási helyének meghatározásához.</string>
<string name="permission_ignore_battery_optimizations_label">Akkumulátor optimalizálás letiltása</string>
<string name="permission_ignore_battery_optimizations_description">Az akkumulátor optimalizálás megakadályozza, hogy ez az alkalmazás megbízhatóan fogadja a Bluetooth-adatokat, miközben az alkalmazás a háttérben működik.</string>
<string name="permission_required_title">A következő engedély szükséges:</string>
<string name="permission_system_alert_window_label">Rendszer figyelmeztető ablak</string>
<string name="permission_system_alert_window_description">Engedélyezze a CAPod számára, hogy más alkalmazások fölé kerüljön, hogy lehetővé tegye a „Előugró ablak” funkciót.</string>
<string name="settings_scanner_mode_lowpower_label">Kis fogyasztású</string>
<string name="settings_scanner_mode_lowpower_label">Takarékos</string>
<string name="settings_scanner_mode_balanced_label">Kiegyensúlyozott</string>
<string name="settings_scanner_mode_lowlatency_label">Alacsony késleltetés</string>
<string name="settings_monitor_mode_manual_label">Amikor az alkalmazás nyitva van</string>
<string name="settings_monitor_mode_manual_label">Amikor az alkalmazás meg van nyitva</string>
<string name="settings_monitor_mode_automatic_label">Amikor az eszköz csatlakoztatva van</string>
<string name="settings_monitor_mode_always_label">Mindig</string>
<string name="settings_reaction_autoconnect_whenseen_label">Amikor látható</string>
<string name="settings_reaction_autoconnect_caseopen_label">Tok Nyitva</string>
<string name="settings_reaction_autoconnect_inear_label">Fülhallgató</string>
<string name="settings_reaction_autoconnect_caseopen_label">Nyitott toknál</string>
<string name="settings_reaction_autoconnect_inear_label">Viseléskor</string>
<string name="pods_dual_left_label">Bal hangszóró</string>
<string name="pods_dual_right_label">Jobb hangszóró</string>
<string name="pods_case_label">Tok</string>
<string name="pods_case_status_open_label">Nyitott</string>
<string name="pods_case_status_closed_label">Zárt</string>
<string name="pods_connection_state_disconnected_label">Nincs eszközhöz csatlakoztatva</string>
<string name="pods_connection_state_idle_label">Eszközhöz csatlakozik, de tétlen</string>
<string name="pods_connection_state_music_label">Zenei mód</string>
<string name="pods_case_status_open_label">Nyitva</string>
<string name="pods_case_status_closed_label">Zárva</string>
<string name="pods_connection_state_disconnected_label">Nincs eszköz csatlakoztatva</string>
<string name="pods_connection_state_idle_label">Eszközhöz csatlakozva, de az tétlen</string>
<string name="pods_connection_state_music_label">Zene üzemmód</string>
<string name="pods_connection_state_call_label">Hívás üzemmód</string>
<string name="pods_connection_state_ringing_label">Hívás</string>
<string name="pods_connection_state_ringing_label">Csengés</string>
<string name="pods_connection_state_hanging_up_label">Hívás vége</string>
<string name="pods_connection_state_unknown_label">Ismeretlen kapcsolati állapot</string>
<string name="pods_unknown_raw_data_label">Rossz adat</string>
<string name="pods_unknown_raw_data_label">Nyers adat</string>
<string name="pods_unknown_label">Ismeretlen eszköz</string>
<string name="pods_unknown_contact_dev">Ez egy ismeretlen eszköz, de hasonló üzenetformátumot használ. Adjunk hozzá támogatást, vedd fel velem a kapcsolatot :)</string>
<string name="pods_unknown_contact_dev">Ez egy ismeretlen eszköz, de hasonló üzenetformátumot használ. Vegye fel velem a kapcsolatot, hogy támogatni tudjam :)</string>
<string name="pods_none_label_short">Nincs eszköz</string>
<string name="pods_charging_label">Töltés</string>
<string name="pods_inear_label">Fülhalgató</string>
<string name="pods_charging_label">Töltődik</string>
<string name="pods_inear_label">Viselve</string>
<string name="pods_microphone_label">Mikrofon</string>
<string name="pods_yours">A tied</string>
<string name="headset_being_worn_label">Használt</string>
<string name="pods_yours">Saját</string>
<string name="headset_being_worn_label">Viselve</string>
<string name="headset_not_being_worn_label">Nincs viselve</string>
<string name="pods_case_unknown_state">Ismeretlen állapot</string>
<string name="last_seen_x">Utoljára volt látható %s</string>
<string name="first_seen_x">Először volt látható: %s</string>
<string name="last_seen_x">Utoljára %s volt látható</string>
<string name="first_seen_x">Először %s volt látható</string>
<string name="permission_post_notifications_label">Értesítések megjelenítése</string>
<string name="permission_post_notifications_description">"Engedélyezze a CAPod számára, hogy értesítéseket jelenítsen meg az AirPodsról, pl. aktuális állapotot csatlakozás közben."</string>
</resources>
@@ -55,7 +55,10 @@
<string name="pods_microphone_label">Mikrofon</string>
<string name="pods_yours">Milikmu</string>
<string name="headset_being_worn_label">Sedang dipakai</string>
<string name="headset_not_being_worn_label">Tidak dipakai</string>
<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">Tampilkan notifikasi</string>
<string name="permission_post_notifications_description">"Izinkan CAPod untuk menampilkan notifikasi tentang AirPods anda, contoh. status saat terhubung."</string>
</resources>
@@ -3,7 +3,7 @@
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_value_not_available_label">Non disponibile</string>
<string name="general_error_label">Errore</string>
<string name="general_grant_permission_action">Concedi permessi</string>
<string name="overview_nomaindevice_label">Nessun device primario</string>
@@ -33,14 +33,14 @@
<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 sinistro</string>
<string name="pods_dual_right_label">Pod destro</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>
@@ -51,10 +51,11 @@
<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="headset_not_being_worn_label">Non 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>
@@ -8,13 +8,13 @@
<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_label">בלוטות כבוי</string>
<string name="overview_bluetooth_disabled_description">בלוטות\' מכובה, יש להדליקו ;)</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">בלוטות</string>
<string name="permission_bluetooth_connect_label">בלוטות חיבור</string>
<string name="permission_bluetooth_connect_description">אפליקציה זו דורשת הרשאת \"חיבור בלוטוס\" כדי ליצור אינטראקציה עם מכשירים מותאמים וליזום חיבורים.</string>
<string name="permission_bluetooth_scan_label">סריקת בלוטוס</string>
<string name="permission_bluetooth_scan_description">הרשאת \"סריקת בלוטוס\" מאפשרת לאפליקציה הזו לגלות ולקבל נתוני בלוטוס ממכשירים קרובים כמו ה-איירפודס שלך.</string>
<string name="permission_bluetooth_label">בלוטוס</string>
<string name="permission_bluetooth_description">אפליקציה זו דורשת הרשאת \"בלוטות\'\" כדי להתחבר למכשירי בלוטות\' מותאמים.</string>
<string name="permission_access_fine_location_label">גישה למיקום מדוייק</string>
<string name="permission_access_fine_location_description">CAPod משתמש בהרשאת \"שיפור מיקום\" כדי לקבל הפחתת צריכת אנרגיה בנתוני Bluetooth. האוזניות שלך משתמשות בטכנולוגיית Bluetooth Low Energy כדי לשדר את הסטטוס שלהן. אפליקציה זו לא תשתמש בנתוני Bluetooth כדי לקבוע את מיקומך.</string>
@@ -55,7 +55,10 @@
<string name="pods_microphone_label">מיקרופון</string>
<string name="pods_yours">שנים</string>
<string name="headset_being_worn_label">להיות משוחק</string>
<string name="headset_not_being_worn_label">לא נלבש</string>
<string name="pods_case_unknown_state">מצב לא ידוע</string>
<string name="last_seen_x">נראה לאחרונה: %s</string>
<string name="first_seen_x">נראה לראשונה: %s</string>
<string name="permission_post_notifications_label">הצג התראות</string>
<string name="permission_post_notifications_description">"אפשר ל-CAPod להציג התראות על ה-AirPods שלך, למשל. המצב הנוכחי שלהם בזמן שהם מחוברים."</string>
</resources>
@@ -1,8 +1,5 @@
<?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>
@@ -14,7 +11,6 @@
<string name="permission_bluetooth_connect_description">このアプリでは、ペアリングされたデバイスとやり取りして接続を開始するには、「Bluetooth接続」の権限が必要です。</string>
<string name="permission_bluetooth_scan_label">Bluetoothスキャン</string>
<string name="permission_bluetooth_scan_description">「Bluetoothスキャン」権限により、このアプリはAirPodsなどの近くのデバイスからBluetoothのデータを検出して受信することができます。</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">このアプリは、ペアリングされたBluetoothデバイスに接続するために「Bluetooth」権限が必要です。</string>
<string name="permission_access_fine_location_label">正確な位置情報</string>
<string name="permission_access_fine_location_description">CAPodは、Bluetooth Low Energyデータを受信するために、「正確な位置情報」権限の許可を使用します。ヘッドホンはBluetooth Low Energyテクノロジーを使用してデバイスの状態を送信します。このアプリは、Bluetoothデータを使用して現在地を特定することはありません。</string>
+17 -14
View File
@@ -3,7 +3,7 @@
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</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>
@@ -33,29 +33,32 @@
<string name="settings_monitor_mode_always_label">항상</string>
<string name="settings_reaction_autoconnect_whenseen_label">기기가 보일 때</string>
<string name="settings_reaction_autoconnect_caseopen_label">케이스 열림</string>
<string name="settings_reaction_autoconnect_inear_label">귀에 있음</string>
<string name="pods_dual_left_label">왼쪽</string>
<string name="pods_dual_right_label">오른쪽</string>
<string name="settings_reaction_autoconnect_inear_label">착용 중</string>
<string name="pods_dual_left_label">왼쪽 버드</string>
<string name="pods_dual_right_label">오른쪽 버드</string>
<string name="pods_case_label">케이스</string>
<string name="pods_case_status_open_label">열림</string>
<string name="pods_case_status_closed_label">닫힘</string>
<string name="pods_connection_state_disconnected_label">기기에 연결되지 않음</string>
<string name="pods_connection_state_idle_label">작동하고 있지 않은 기기에 연결됨</string>
<string name="pods_connection_state_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_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_connection_state_unknown_label">상태 알 수 없음</string>
<string name="pods_unknown_raw_data_label"> 데이터</string>
<string name="pods_unknown_label">알 수 없는 기기</string>
<string name="pods_unknown_contact_dev">알 수 없는 기기이나 블루투스 헤드폰과 비슷한 기기인 것 같습니다. 제게 알려주시면 앱에 해당 기기 지원을 추가해 드릴게요 :)</string>
<string name="pods_none_label_short">기기 없음</string>
<string name="pods_charging_label">충전 중</string>
<string name="pods_inear_label">귀에 있음</string>
<string name="pods_inear_label">착용 중</string>
<string name="pods_microphone_label">마이크</string>
<string name="pods_yours">소유함</string>
<string name="headset_being_worn_label">쓰고 있음</string>
<string name="pods_yours">내 기기</string>
<string name="headset_being_worn_label">착용 중</string>
<string name="headset_not_being_worn_label">착용 안됨</string>
<string name="pods_case_unknown_state">알 수 없는 상태</string>
<string name="last_seen_x">마지막으로 확인: %s</string>
<string name="first_seen_x">처음 확인: %s</string>
<string name="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>
@@ -11,7 +11,6 @@
<string name="permission_bluetooth_connect_description">Apl ini memerlukan kebenaran \"Bluetooth connect\" untuk berinteraksi dengan pasangan peranti dan memulakan sambungan.</string>
<string name="permission_bluetooth_scan_label">Pengimbasan bluetooth</string>
<string name="permission_bluetooth_scan_description">Kebenaran \"Pengimbasan Bluetooth\" membolehkan apl ini menemui dan menerima data Bluetooth daripada peranti berdekatan seperti AirPods anda.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Apl ini memerlukan kebenaran \"Bluetooth\" untuk menyambung ke peranti bluetooth yang dipasangkan.</string>
<string name="permission_access_fine_location_label">Akses lokasi yang baik</string>
<string name="permission_access_fine_location_description">CAPod menggunakan kebenaran \"lokasi baik\" untuk menerima data Bluetooth Tenaga Rendah. Fon kepala anda menggunakan teknologi Bluetooth Tenaga Rendah untuk menyiarkan statusnya. Apl ini TIDAK akan menggunakan data Bluetooth untuk menentukan lokasi anda.</string>
@@ -3,7 +3,7 @@
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_value_not_available_label">n.v.t.</string>
<string name="general_error_label">Fout</string>
<string name="general_grant_permission_action">Toestemming geven</string>
<string name="overview_nomaindevice_label">Geen primair apparaat</string>
@@ -37,7 +37,7 @@
<string name="pods_dual_left_label">Linker pod</string>
<string name="pods_dual_right_label">Rechter pod</string>
<string name="pods_case_label">Oplaadcase</string>
<string name="pods_case_status_open_label">Open</string>
<string name="pods_case_status_open_label">Openen</string>
<string name="pods_case_status_closed_label">Gesloten</string>
<string name="pods_connection_state_disconnected_label">Niet aangesloten op een apparaat</string>
<string name="pods_connection_state_idle_label">Aangesloten op een apparaat, maar inactief</string>
@@ -55,6 +55,7 @@
<string name="pods_microphone_label">Microfoon</string>
<string name="pods_yours">Die van u</string>
<string name="headset_being_worn_label">Wordt gedragen</string>
<string name="headset_not_being_worn_label">Wordt niet gedragen</string>
<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>
@@ -3,7 +3,7 @@
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_value_not_available_label">Ikke tilgjengelig</string>
<string name="general_error_label">Feil</string>
<string name="general_grant_permission_action">Gi tillatelse</string>
<string name="overview_nomaindevice_label">Ingen primær enhet</string>
@@ -55,6 +55,7 @@
<string name="pods_microphone_label">Mikrofon</string>
<string name="pods_yours">Dine</string>
<string name="headset_being_worn_label">Brukes</string>
<string name="headset_not_being_worn_label">Brukes ikke</string>
<string name="pods_case_unknown_state">Ukjent status</string>
<string name="last_seen_x">Sist sett: %s</string>
<string name="first_seen_x">Først sett: %s</string>
@@ -55,6 +55,7 @@
<string name="pods_microphone_label">Mikrofon</string>
<string name="pods_yours">Twoje</string>
<string name="headset_being_worn_label">Jest noszony</string>
<string name="headset_not_being_worn_label">Nie był noszony</string>
<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>
@@ -1,9 +1,6 @@
<?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">Fóssil CAPod</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_error_label">Erro</string>
<string name="general_grant_permission_action">Conceder permissão</string>
<string name="overview_nomaindevice_label">Nenhum dispositivo principal</string>
@@ -14,7 +11,6 @@
<string name="permission_bluetooth_connect_description">Este aplicativo requer a permissão \"Conexão Bluetooth\" para interagir com dispositivos emparelhados e iniciar conexões.</string>
<string name="permission_bluetooth_scan_label">Escaneando bluetooth</string>
<string name="permission_bluetooth_scan_description">A permissão \"Escaneamento de Bluetooth\" permite que este aplicativo descubra e receba dados de Bluetooth de dispositivos próximos, como seus AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Este aplicativo requer a permissão \"Bluetooth\" para se conectar a dispositivos bluetooth emparelhados.</string>
<string name="permission_access_fine_location_label">Acesse a boa localização</string>
<string name="permission_access_fine_location_description">O CAPod usa a permissão \"Boa localização\" para receber dados de Bluetooth Low Energy. Seus fones de ouvido usam a tecnologia Bluetooth Low Energy para transmitir seu status. Este aplicativo NÃO usará dados Bluetooth para determinar sua localização.</string>
@@ -58,4 +54,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">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>
@@ -1,9 +1,5 @@
<?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">N/A</string>
<string name="general_error_label">Erro</string>
<string name="general_grant_permission_action">Conceder permissão</string>
<string name="overview_nomaindevice_label">Sem dispositivo primário</string>
@@ -14,7 +10,6 @@
<string name="permission_bluetooth_connect_description">Esta aplicação requer permissão para conectar com o Bluetooth para interagir com dispositivos emparelhados e iniciar conexões.</string>
<string name="permission_bluetooth_scan_label">Explorar Bluetooth</string>
<string name="permission_bluetooth_scan_description">A permissão para explorar o Bluetooth autoriza esta aplicação para descobrir e detetar dados de Bluetooth de dispositivos por perto como os seus AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Esta aplicação requer a permissão do Bluetooth para conectar e emparelhar a dispositivos bluetooth.</string>
<string name="permission_access_fine_location_label">Aceder a localização exata</string>
<string name="permission_access_fine_location_description">CAPod utiliza a localização exata para receber dados de \'Bluetooth Low Energy\'. Os seus headphones utilizam tecnologia \'Bluetooth Low Energy\' para transmitir o seu estado. Esta aplicação NÃO irá utilizar dados de Bluetooth para determinar a sua localização.</string>
+57 -1
View File
@@ -1,2 +1,58 @@
<?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="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_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>
@@ -1,8 +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="app_name_pro"></string>
<string name="app_name_foss"></string>
<string name="general_value_not_available_label">Недоступно</string>
<string name="general_error_label">Ошибка</string>
<string name="general_grant_permission_action">Предоставить разрешение</string>
@@ -14,7 +14,7 @@
<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_label"></string>
<string name="permission_bluetooth_description">Этому приложению требуется разрешение\"Bluetooth\" для подключения к сопряженным устройствам.</string>
<string name="permission_access_fine_location_label">Доступ к точному местоположению</string>
<string name="permission_access_fine_location_description">CAPod необходимо разрешение \"Точное местоположение\" для получения данных Bluetooth Low Energy. Ваши наушники задействуют технологию Bluetooth Low Energy (Bluetooth с низким энергопотреблением) для передачи своего состояния. Это приложение НЕ будет использовать данные Bluetooth для определения Вашего местоположения.</string>
@@ -55,6 +55,7 @@
<string name="pods_microphone_label">Микрофон</string>
<string name="pods_yours">Ваши</string>
<string name="headset_being_worn_label">Надеты</string>
<string name="headset_not_being_worn_label"></string>
<string name="pods_case_unknown_state">Неизвестное состояние</string>
<string name="last_seen_x">Последние: %s</string>
<string name="first_seen_x">Первые: %s</string>
@@ -55,6 +55,7 @@
<string name="pods_microphone_label">Mikrofón</string>
<string name="pods_yours">Vaše</string>
<string name="headset_being_worn_label">Používa sa</string>
<string name="headset_not_being_worn_label">Nenosené</string>
<string name="pods_case_unknown_state">Neznámy stav</string>
<string name="last_seen_x">Naposledy videné: %s</string>
<string name="first_seen_x">Prvýkrát videné: %s</string>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_pro">CAPod </string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">Kapalı</string>
<string name="general_error_label">Hata</string>
@@ -55,6 +55,7 @@
<string name="pods_microphone_label">Mikrofon</string>
<string name="pods_yours">Sizinki</string>
<string name="headset_being_worn_label">Takılıyken</string>
<string name="headset_not_being_worn_label">Giyilmiyor</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>
@@ -55,6 +55,7 @@
<string name="pods_microphone_label">Мікрофон</string>
<string name="pods_yours">Ваш</string>
<string name="headset_being_worn_label">Одягнені</string>
<string name="headset_not_being_worn_label">Не одягнені</string>
<string name="pods_case_unknown_state">Невідомий стан</string>
<string name="last_seen_x">Востаннє був: %s</string>
<string name="first_seen_x">Уперше був: %s</string>

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