Compare commits

...
49 Commits
Author SHA1 Message Date
darken 1b5b59c41b Release: 2.8.4-rc0 2023-03-13 17:28:28 +01:00
Matthias Urhahn 8df7c19e84 Fix billing client disconnection handling (#117) 2023-03-13 17:28:01 +01:00
darken 88b0069b94 Release: 2.8.3-rc0 2023-03-13 13:48:11 +01:00
Matthias Urhahn 3b8ddc9f06 Fix reversed AirPods Max wear-detection (#116)
* Fix reversed AirPods Max wear-detection
Fixes #115

* Remove unnecessary test
2023-03-13 13:47:32 +01:00
darken 708c7f38ec Merge remote-tracking branch 'origin/main' into main 2023-03-12 14:05:25 +01:00
darken 139daa1cc2 Update full gplay text 2023-03-12 14:05:20 +01:00
Matthias Urhahn b85ac778c0 Address that gplay api can emit multiple times (#114) 2023-03-12 02:58:37 +01:00
Matthias Urhahn a3e2c9e61d Bump dependencies and gradle (#113) 2023-03-11 18:16:24 +01:00
Matthias Urhahn ad78a0570c For some reason we can finish the widget update broadcast twice. (#112)
I can't yet determine why this race-condition occurs, due to the forced timeout, it shouldn't be due to exceeding the receiver ANR window...
2023-03-11 13:01:15 +01:00
Matthias Urhahn 7e6ee274d8 Quick data updates can cause concurrent access and modifying of the notification builder. (#111)
Internally building the notification can then throw a ConcurrentModificationException.
2023-03-11 13:01:05 +01:00
Matthias Urhahn daf7411c36 Show worker foreground notification early. (#110) 2023-03-11 13:00:55 +01:00
darken d3dedbe6cd Release: 2.8.2-rc0 2023-03-10 12:13:30 +01:00
Matthias Urhahn d5411586d5 Update translations (#109)
* Update translations, add Portuguese

* Update translations
2023-03-10 12:12:31 +01:00
Matthias Urhahn 94e2d40554 Fix AirPods Max wear detection. (#108)
Closes #106
2023-03-10 11:30:40 +01:00
Matthias Urhahn db80fa1b46 Wear: Remove unused code (#104)
* Wear: Remove unused code

* Remove more code
2023-03-02 16:39:11 +01:00
Matthias Urhahn 9b569b8d07 Update Turkish translation (#100) 2023-02-20 09:39:27 +01:00
darken 90288880fb Add f-droid as source 2023-02-20 09:14:20 +01:00
darken 870e518ff6 Release: 2.8.1-rc0 2023-02-09 22:01:02 +01:00
darken 6d89169bae Update release.sh script to create default changelogs for f-droid 2023-02-09 21:59:44 +01:00
darken ac77ee2703 Update default changelog for GPlay 2023-02-09 21:53:33 +01:00
darken 18dcf603e9 Release: 2.8.0-rc0 2023-02-03 22:32:01 +01:00
Matthias Urhahn 71aaba1b86 Fix data display for non dual apple pods (#98)
* Fix data display for fake pods and beats devices (with two pods)

* Rename methods
2023-02-03 22:31:23 +01:00
Matthias Urhahn 64b9d5f17a Remove ~ and % from signal quality value, it's not necessarily a 0 to 100 percent indicator. (#96) 2023-02-02 16:47:06 +01:00
Matthias Urhahn a9f23c4bfe Change defaults setting for "Show all devices" (#95)
* Fix potential widget issue where a broadcast race condition causes the goAsync() to fail.

* Enable "show all devices" by default to help new users.
2023-02-02 16:46:52 +01:00
Matthias Urhahn f454f46778 Fix potential widget issue where a broadcast race condition causes the goAsync() to fail. (#94) 2023-02-02 16:46:36 +01:00
Matthias Urhahn 432936ea2c Merge pull request #93 from d4rken-org/crowd_reliability
Improve tracking in crowds
2023-02-02 16:46:08 +01:00
Matthias Urhahn 8211b0ac88 Track case battery state outside of history list and simplify history clean up. (#92) 2023-02-02 16:45:39 +01:00
Matthias Urhahn c314987e13 Fix popup icon sizes (#91) 2023-02-02 16:45:22 +01:00
Matthias Urhahn fc6cd74752 If the current pods don't have a lid state value, and there is none in recent history, we can assume NOT_IN_CASE. (#90) 2023-02-02 16:45:05 +01:00
Matthias Urhahn a47011e579 Merge branch 'main' into crowd_reliability 2023-02-02 14:57:04 +01:00
darken fbeeec6539 Boost signal quality rating by up to 25% depending on how long they have been tracked. 2023-02-02 14:46:29 +01:00
darken e1cd79cd9d Cap reliability at 100% 2023-02-02 14:45:44 +01:00
darken 692ced1ee7 Timeout after 30 not 20 seconds. 2023-02-02 14:45:22 +01:00
darken c5cc127df4 Smooth rssi values and use reliability (missed signals) as boost, not as factor. 2023-02-02 11:50:33 +01:00
Matthias Urhahn 5f00f10ae8 Remove short hash from generated apk file names (#89) 2023-02-02 08:38:22 +01:00
Matthias Urhahn 0fc9716f06 Only apply Bugsnag to gplay build flavor (#88)
* wip

* Setup bugsnag only in gplay builds.

* Bugsnag does not support uploading mapping files or build information from library modules. This should be done from the application module which produces your APK instead.

* Only apply Bugsnag plugin for the right flavors.

* Run unit tests in debug, otherwise we run issues requiring api keys in the env
2023-02-01 11:17:50 +01:00
darken 48fd3543e3 Release: 2.7.2-rc0 2023-01-30 13:38:53 +01:00
Matthias Urhahn 41985794c2 Adjust supported AirPods Max features. (#86)
Remove state detection (was UNKNOWN when it should have been MUSIC).
Add PodStyle detection.
2023-01-30 13:37:40 +01:00
Matthias Urhahn 87a92fd001 Fix feature display for single pods inside the app. (#87) 2023-01-30 13:37:31 +01:00
darken f6f53f2a15 Release: 2.7.1-rc1 2023-01-28 22:14:47 +01:00
Matthias Urhahn 2531769b79 Update translations (#85)
Add arabic and update chinese (TW)
2023-01-28 22:12:25 +01:00
darken e9fa9f6cb8 Release: 2.7.0-rc0 2023-01-25 16:54:32 +01:00
Matthias Urhahn b98ca86886 Faster CI checks (#83)
* Parallelize CI code checks

* Only assemble debug variant, otherwise we need to setup signing.
2023-01-25 16:47:46 +01:00
Matthias Urhahn d917c8ffe6 New compatibility option for receiving BLE data (#82)
* Improve compat options: Add alternative method for receiving BLE scan results via PendingIntents

* Add missing logtags

* Reduce log spam

* Make the linter happy
2023-01-25 16:27:29 +01:00
Matthias Urhahn 5d44737199 Merge pull request #80 from d4rken-org/compatibility_options
Improve compatibility options
2023-01-25 14:56:10 +01:00
Matthias Urhahn 6e8693a6b0 Merge pull request #81 from d4rken-org/modularize_permissions
Move shared permissions into the common module
2023-01-25 14:55:54 +01:00
darken 6d9e7404a1 Improve compatibility options 2023-01-25 14:46:20 +01:00
darken 70ceb389b5 Move shared permissions into the common module. 2023-01-25 14:44:38 +01:00
darken 832e364a2f Update translations 2023-01-25 09:26:50 +01:00
127 changed files with 1318 additions and 713 deletions
+30 -12
View File
@@ -7,10 +7,14 @@ on:
branches: [ main ]
jobs:
build-and-test:
name: Build and test
build-all:
name: Assemble all variants
strategy:
fail-fast: false
matrix:
flavor: [ Foss,Gplay ]
variant: [ Debug ]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up JDK 11
@@ -19,16 +23,30 @@ jobs:
java-version: '11'
distribution: 'adopt'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build FOSS variant
run: ./gradlew assembleFossDebug
- name: Test FOSS variant
run: ./gradlew testFossDebugUnitTest
- name: Build (Flavor ${{ matrix.flavor }}, Variant ${{ matrix.variant }})
run: ./gradlew assemble${{ matrix.flavor }}${{ matrix.variant }}
- name: Build Google Play variant
run: ./gradlew assembleGplayDebug
- name: Test Google Play variant
run: ./gradlew testGplayDebugUnitTest
test-all:
name: Run all tests
strategy:
fail-fast: false
matrix:
flavor: [ Foss,Gplay ]
variant: [ Release ]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up JDK 11
uses: actions/setup-java@v2
with:
java-version: '11'
distribution: 'adopt'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run tests (Flavor ${{ matrix.flavor }}, Variant ${{ matrix.variant }})
run: ./gradlew test${{ matrix.flavor }}${{ matrix.variant }}
+1
View File
@@ -12,3 +12,4 @@
*.jks
.local/*
/fastlane/report.xml
/fastlane/Appfile
+6 -5
View File
@@ -43,12 +43,13 @@ Currently supported models:
## Download
| Source | Status |
|------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Source | Status |
|------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [![](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%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) |
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [![](https://img.shields.io/badge/Google%20Play-Beta-yellowgreen?style=flat&logo=google-play)](https://play.google.com/apps/testing/eu.darken.capod) | |
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases&label=GitHub)](https://github.com/d4rken-org/capod/releases/latest) |
| [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/) |
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [![](https://img.shields.io/badge/Google%20Play-Beta-yellowgreen?style=flat&logo=google-play)](https://play.google.com/apps/testing/eu.darken.capod) | |
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases&label=GitHub)](https://github.com/d4rken-org/capod/releases/latest) |
| [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
+1 -1
View File
@@ -1 +1 @@
2.6.1-rc0 20601000
2.8.4-rc0 20804000
+6 -1
View File
@@ -21,6 +21,10 @@ android {
buildConfigField("String", "APPLICATION_ID", "\"${ProjectConfig.packageName}\"")
buildConfigField("String", "GITSHA", "\"${lastCommitHash()}\"")
buildConfigField("String", "BUILDTIME", "\"${buildTime()}\"")
manifestPlaceholders["bugsnagApiKey"] = getBugSnagApiKey(
File(System.getProperty("user.home"), ".appconfig/${ProjectConfig.packageName}/bugsnag.properties")
) ?: "bugsnag_apikey_placeholder"
}
buildFeatures {
@@ -95,6 +99,7 @@ dependencies {
addTesting()
implementation("com.bugsnag:bugsnag-android:5.9.2")
"gplayImplementation"("com.bugsnag:bugsnag-android:5.9.2")
"gplayImplementation"("com.getkeepsafe.relinker:relinker:1.4.3")
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>
@@ -0,0 +1,17 @@
package eu.darken.capod.debug.autoreport
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.debug.autoreport.FossAutoReporting
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
abstract class AutoReportingModule {
@Binds
@Singleton
abstract fun autoreporting(foss: FossAutoReporting): AutomaticBugReporter
}
@@ -0,0 +1,17 @@
package eu.darken.capod.debug.autoreport
import android.app.Application
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FossAutoReporting @Inject constructor() : AutomaticBugReporter {
override fun setup(application: Application) {
// NOOP
}
override fun notify(throwable: Throwable) {
throw IllegalStateException("Who initliazed this? Without setup no calls to here!")
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<meta-data
android:name="com.bugsnag.android.API_KEY"
android:value="${bugsnagApiKey}" />
</application>
</manifest>
@@ -0,0 +1,16 @@
package eu.darken.capod.debug.autoreport
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
abstract class AutoReportingModule {
@Binds
@Singleton
abstract fun autoreporting(foss: GplayAutoReporting): AutomaticBugReporter
}
@@ -1,4 +1,4 @@
package eu.darken.capod.common.debug.autoreport.bugsnag
package eu.darken.capod.debug.autoreport
import android.annotation.SuppressLint
import android.content.Context
@@ -7,7 +7,7 @@ import com.bugsnag.android.Event
import com.bugsnag.android.OnErrorCallback
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
@@ -1,4 +1,4 @@
package eu.darken.capod.common.debug.autoreport.bugsnag
package eu.darken.capod.debug.autoreport
import com.bugsnag.android.Event
import eu.darken.capod.common.debug.logging.Logging
@@ -1,15 +1,16 @@
package eu.darken.capod.common.debug.autoreport
package eu.darken.capod.debug.autoreport
import android.app.Application
import android.content.Context
import com.bugsnag.android.Bugsnag
import com.bugsnag.android.Configuration
import com.getkeepsafe.relinker.ReLinker
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.InstallId
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.autoreport.bugsnag.BugsnagErrorHandler
import eu.darken.capod.common.debug.autoreport.bugsnag.BugsnagLogger
import eu.darken.capod.common.debug.autoreport.bugsnag.NOPBugsnagErrorHandler
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
@@ -18,19 +19,25 @@ import javax.inject.Provider
import javax.inject.Singleton
@Singleton
class AutoReporting @Inject constructor(
class GplayAutoReporting @Inject constructor(
@ApplicationContext private val context: Context,
private val debugSettings: DebugSettings,
private val installId: InstallId,
private val bugsnagLogger: Provider<BugsnagLogger>,
private val bugsnagErrorHandler: Provider<BugsnagErrorHandler>,
private val nopBugsnagErrorHandler: Provider<NOPBugsnagErrorHandler>,
) {
) : AutomaticBugReporter {
fun setup() {
override fun setup(application: Application) {
val isEnabled = debugSettings.isAutoReportingEnabled.value
log(TAG) { "setup(): isEnabled=$isEnabled" }
if (!isEnabled) return
ReLinker
.log { message -> log(TAG) { "ReLinker: $message" } }
.loadLibrary(application, "bugsnag-plugin-android-anr")
try {
val bugsnagConfig = Configuration.load(context).apply {
if (debugSettings.isAutoReportingEnabled.value) {
@@ -48,13 +55,18 @@ class AutoReporting @Inject constructor(
}
Bugsnag.start(context, bugsnagConfig)
Bugs.ready = true
Bugs.reporter = this
} catch (e: IllegalStateException) {
log(TAG) { "Bugsnag API Key not configured." }
}
}
override fun notify(throwable: Throwable) {
Bugsnag.notify(throwable)
}
companion object {
private val TAG = logTag("Debug", "AutoReport")
}
}
@@ -1,4 +1,4 @@
package eu.darken.capod.common.debug.autoreport.bugsnag
package eu.darken.capod.debug.autoreport
import com.bugsnag.android.Event
import com.bugsnag.android.OnErrorCallback
+32 -1
View File
@@ -1,4 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.darken.capod.common">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.darken.capod.common">
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<application>
<receiver
android:name=".bluetooth.BleScanResultReceiver"
android:exported="false">
<intent-filter>
<action android:name="eu.darken.capod.bluetooth.DELIVER_SCAN_RESULTS" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,10 @@
import android.content.BroadcastReceiver
import eu.darken.capod.common.debug.logging.log
fun BroadcastReceiver.PendingResult.finish2(): Boolean = try {
finish()
true
} catch (e: IllegalStateException) {
log { "BroadcastReceiver.PendingResult.finish() failed: $e" }
false
}
@@ -0,0 +1,33 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.le.ScanResult
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 kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BleScanResultForwarder @Inject constructor() {
private val forwarder = MutableSharedFlow<Collection<ScanResult>>(
replay = 0,
extraBufferCapacity = 128,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val results: Flow<Collection<ScanResult>> = forwarder
fun forward(scanResults: Collection<ScanResult>) {
log(TAG, VERBOSE) { "forward($scanResults)" }
val success = forwarder.tryEmit(scanResults)
if (!success) log(TAG, WARN) { "Failed to forward (overflow?) $scanResults" }
}
companion object {
private val TAG = logTag("Bluetooth", "BleScanner", "Forwarder")
}
}
@@ -0,0 +1,64 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanResult
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.coroutine.AppScope
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class BleScanResultReceiver : BroadcastReceiver() {
@Inject @AppScope lateinit var appScope: CoroutineScope
@Inject lateinit var scanResultForwarder: BleScanResultForwarder
override fun onReceive(context: Context, intent: Intent) {
log(TAG, VERBOSE) { "onReceive($context, $intent)" }
if (intent.action != ACTION) {
log(TAG, WARN) { "Unknown action: ${intent.action}" }
return
}
if (intent.extras == null) {
log(TAG) { "Extras are null!" }
return
}
val errorCode = intent.getIntExtra(BluetoothLeScanner.EXTRA_ERROR_CODE, 0)
log(TAG, VERBOSE) { "errorCode=$errorCode" }
if (errorCode != 0) {
log(TAG, WARN) { "ScanCallback error code: $errorCode" }
return
}
val callbackType = intent.getIntExtra(BluetoothLeScanner.EXTRA_CALLBACK_TYPE, -1)
log(TAG, VERBOSE) { "callbackType=$callbackType" }
val scanResults = intent.getParcelableArrayListExtra<ScanResult>(BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT)
log(TAG, VERBOSE) { "scanResults=$scanResults" }
if (scanResults == null) {
log(TAG) { "Scan results were empty!" }
return
}
val pending = goAsync()
appScope.launch {
scanResultForwarder.forward(scanResults)
pending.finish()
}
}
companion object {
private val TAG = logTag("Bluetooth", "BleScanner", "Forwarder", "Receiver")
const val ACTION = "eu.darken.capod.bluetooth.DELIVER_SCAN_RESULTS"
}
}
@@ -1,20 +1,21 @@
package eu.darken.capod.common.bluetooth
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
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.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.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -25,28 +26,48 @@ class BleScanner @Inject constructor(
@ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2,
private val fakeBleData: FakeBleData,
private val scanResultForwarder: BleScanResultForwarder,
) {
@SuppressLint("MissingPermission") fun scan(
filters: Set<ScanFilter>,
scannerMode: ScannerMode,
compatMode: Boolean,
): Flow<List<BleScanResult>> = callbackFlow {
log(TAG, VERBOSE) { "scan(filters=$filters, scannerMode=$scannerMode, compatMode=$compatMode)" }
if (compatMode) log(TAG, WARN) { "Using compatibilityMode!" }
scannerMode: ScannerMode = ScannerMode.BALANCED,
disableOffloadFiltering: Boolean = true,
disableOffloadBatching: Boolean = true,
disableDirectScanCallback: Boolean = true,
): Flow<Collection<BleScanResult>> = callbackFlow {
log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" }
val adapter = bluetoothManager.adapter ?: throw IllegalStateException("Bluetooth adapter unavailable")
val supportsOffloadFiltering = adapter.isOffloadedFilteringSupported.also {
val useOffloadedFiltering = adapter.isOffloadedFilteringSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" }
} && !compatMode
} && !disableOffloadFiltering
if (disableOffloadFiltering) log(TAG, WARN) { "Offloaded filtering is disabled!" }
val supportsOffloadBatching = adapter.isOffloadedScanBatchingSupported.also {
val useOffloadedBatching = adapter.isOffloadedScanBatchingSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" }
} && !compatMode
} && !disableOffloadBatching
if (disableOffloadBatching) log(TAG, WARN) { "Offloaded scan-batching is disabled!" }
if (disableDirectScanCallback) log(TAG, WARN) { "Direct scan callback is disabled!" }
val scanner = bluetoothManager.scanner ?: throw IllegalStateException("BLE scanner unavailable")
val filterResults: (Collection<ScanResult>) -> Collection<BleScanResult> = { results ->
results
.filter { result ->
val passed = when {
useOffloadedFiltering -> true
filters.isEmpty() -> true
else -> filters.any { it.matches(result) }
}
if (!passed) log(TAG, VERBOSE) { "Manually filtered $result" }
passed
}
.map { BleScanResult.fromScanResult(it) }
}
val callback = object : ScanCallback() {
var lastScanAt = System.currentTimeMillis()
override fun onScanResult(callbackType: Int, result: ScanResult) {
@@ -55,17 +76,8 @@ class BleScanner @Inject constructor(
lastScanAt = System.currentTimeMillis()
"onScanResult(delay=${delay}ms, callbackType=$callbackType, result=$result)"
}
val toSend = if (
supportsOffloadFiltering
|| filters.isEmpty()
|| filters.any { it.matchesSafe(result) }
) {
listOf(BleScanResult.fromScanResult(result))
} else {
log(TAG, VERBOSE) { "Manual filtering: No match for $result" }
emptyList()
}
trySend(toSend)
trySend(filterResults(setOf(result)))
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
@@ -75,18 +87,7 @@ class BleScanner @Inject constructor(
"onBatchScanResults(delay=${delay}ms, results=$results)"
}
val toSend = results
.filter { result ->
val passed = when {
supportsOffloadFiltering -> true
filters.isEmpty() -> true
else -> filters.any { it.matches(result) }
}
if (!passed) log(TAG, VERBOSE) { "Manually filtered $result" }
passed
}
.map { BleScanResult.fromScanResult(it) }
trySend(toSend)
trySend(filterResults(results))
}
override fun onScanFailed(errorCode: Int) {
@@ -94,58 +95,113 @@ class BleScanner @Inject constructor(
}
}
val settings = ScanSettings.Builder().apply {
setScanMode(
when (scannerMode) {
ScannerMode.LOW_POWER -> ScanSettings.SCAN_MODE_LOW_POWER
ScannerMode.BALANCED -> ScanSettings.SCAN_MODE_BALANCED
ScannerMode.LOW_LATENCY -> ScanSettings.SCAN_MODE_LOW_LATENCY
}
)
if (supportsOffloadBatching) {
setReportDelay(
when (scannerMode) {
ScannerMode.LOW_POWER -> 2000L
ScannerMode.BALANCED -> 1000L
ScannerMode.LOW_LATENCY -> 500L
}
)
}
}.build()
log(TAG, VERBOSE) { "Settings created for offloaded filtering: $settings" }
val flushJob = launch {
log(TAG) { "Flush job launched" }
while (isActive) {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
log(TAG, VERBOSE) { "Flushing scan results." }
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(1000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
val forwarderConsumer = if (disableDirectScanCallback) {
scanResultForwarder.results
.onEach { results -> trySend(filterResults(results)) }
.launchIn(this)
} else {
null
}
scanner.startScan(
if (supportsOffloadFiltering) filters.toList() else listOf(ScanFilter.Builder().build()),
settings,
callback
)
log(TAG) { "BleScanner started (filters=$filters, settings=$settings)" }
val flushJob = if (!disableDirectScanCallback) {
launch {
log(TAG) { "Flush job launched" }
while (isActive) {
log(TAG, VERBOSE) { "Flushing scan results." }
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
}
} else {
null
}
val filterList = when {
useOffloadedFiltering -> filters.toList()
else -> emptyList()
}
val scanSettings = ScanSettings.Builder().apply {
setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
when (scannerMode) {
ScannerMode.LOW_POWER -> {
setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
setMatchMode(ScanSettings.MATCH_MODE_STICKY)
setNumOfMatches(ScanSettings.MATCH_NUM_FEW_ADVERTISEMENT)
}
ScannerMode.BALANCED -> {
setScanMode(ScanSettings.SCAN_MODE_BALANCED)
setMatchMode(ScanSettings.MATCH_MODE_STICKY)
setNumOfMatches(ScanSettings.MATCH_NUM_FEW_ADVERTISEMENT)
}
ScannerMode.LOW_LATENCY -> {
setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
}
}
val delay = if (useOffloadedBatching) {
when (scannerMode) {
ScannerMode.LOW_POWER -> 2000L
ScannerMode.BALANCED -> 1000L
ScannerMode.LOW_LATENCY -> 500L
}
} else {
0L // Anything > 0 enables batching
}
setReportDelay(delay)
}.build()
if (disableDirectScanCallback) {
val callbackIntent = createStartIntent()
log(TAG) { "Intent callback: startScan(filters=$filters, settings=$scanSettings, callbackIntent=$callbackIntent)" }
scanner.startScan(filterList, scanSettings, callbackIntent)
} else {
log(TAG) { "Direct callback: startScan(filters=$filters, settings=$scanSettings, callback=$callback)" }
scanner.startScan(filterList, scanSettings, callback)
}
awaitClose {
flushJob.cancel()
scanner.stopScan(callback)
forwarderConsumer?.cancel()
flushJob?.cancel()
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
}
log(TAG) { "BleScanner stopped" }
}
}
.map { fakeBleData.maybeAddfakeData(it) }
private val receiverIntent by lazy {
Intent(context, BleScanResultReceiver::class.java).apply {
action = BleScanResultReceiver.ACTION
}
}
private fun createStartIntent(): PendingIntent = PendingIntent.getBroadcast(
context,
CALLBACK_INTENT_REQUESTCODE,
receiverIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE
)
private fun createStopIntent(): PendingIntent = PendingIntent.getBroadcast(
context,
270,
receiverIntent,
PendingIntentCompat.FLAG_IMMUTABLE
)
companion object {
private const val CALLBACK_INTENT_REQUESTCODE = 270
private val TAG = logTag("Bluetooth", "BleScanner")
}
}
@@ -2,7 +2,7 @@ package eu.darken.capod.common.bluetooth
import dagger.Reusable
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.DebugSettings
import java.time.Instant
import javax.inject.Inject
import kotlin.random.Random
@@ -12,7 +12,7 @@ class FakeBleData @Inject constructor(
private val debugSettings: DebugSettings,
) {
fun maybeAddfakeData(originals: List<BleScanResult>): List<BleScanResult> {
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
if (!debugSettings.showFakeData.value) return originals
return originals + getFakeData()
}
@@ -27,9 +27,7 @@ class FakeBleData @Inject constructor(
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())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
fakeDevices.add(this)
}
// AirPods Gen2
@@ -40,9 +38,7 @@ class FakeBleData @Inject constructor(
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())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
fakeDevices.add(this)
}
// AirPods Gen3
@@ -53,9 +49,7 @@ class FakeBleData @Inject constructor(
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())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
fakeDevices.add(this)
}
// AirPods Max
BleScanResult(
@@ -65,9 +59,7 @@ class FakeBleData @Inject constructor(
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())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
fakeDevices.add(this)
}
// BeatsFlex
BleScanResult(
@@ -77,9 +69,7 @@ class FakeBleData @Inject constructor(
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())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
fakeDevices.add(this)
}
// Tws i99999
@@ -90,9 +80,7 @@ class FakeBleData @Inject constructor(
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
fakeDevices.add(this)
}
// Unknown Device
@@ -103,9 +91,7 @@ class FakeBleData @Inject constructor(
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())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
fakeDevices.add(this)
}
return fakeDevices
@@ -0,0 +1,10 @@
package eu.darken.capod.common.collections
fun Collection<Int>.median(): Int = this.sorted().let {
if (it.size % 2 == 0) {
(it[it.size / 2] + it[(it.size - 1) / 2]) / 2
} else {
it[it.size / 2]
}
}
@@ -1,13 +1,13 @@
package eu.darken.capod.common.debug
import com.bugsnag.android.Bugsnag
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
object Bugs {
var ready = false
var reporter: AutomaticBugReporter? = null
fun report(
tag: String,
message: String,
@@ -16,11 +16,9 @@ object Bugs {
log(TAG, VERBOSE) { "Reporting $exception" }
log(tag, ERROR) { "$message\n${exception.asLog()}" }
if (!ready) {
reporter?.notify(exception) ?: run {
log(TAG, WARN) { "Bug tracking not initialized yet." }
return
}
Bugsnag.notify(exception)
}
private val TAG = logTag("Bugs")
@@ -1,4 +1,4 @@
package eu.darken.capod.common.debug.autoreport
package eu.darken.capod.common.debug
import android.content.Context
import android.content.SharedPreferences
@@ -0,0 +1,10 @@
package eu.darken.capod.common.debug.autoreport
import android.app.Application
interface AutomaticBugReporter {
fun setup(application: Application)
fun notify(throwable: Throwable)
}
@@ -9,4 +9,9 @@ object PendingIntentCompat {
} else {
0
}
val FLAG_MUTABLE: Int = if (hasApiLevel(31)) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
}
@@ -6,7 +6,7 @@ import androidx.preference.PreferenceDataStore
import com.squareup.moshi.Moshi
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.debug.autoreport.DebugSettings
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
@@ -23,51 +23,32 @@ 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 monitorMode = preferences.createFlowPreference("core.monitor.mode", MonitorMode.AUTOMATIC, moshi)
val scannerMode = preferences.createFlowPreference("core.scanner.mode", ScannerMode.BALANCED, moshi)
val scannerMode = preferences.createFlowPreference(
"core.scanner.mode",
ScannerMode.LOW_LATENCY,
moshi
)
val showAll = preferences.createFlowPreference("core.showall.enabled", true)
val compatibilityMode = preferences.createFlowPreference(
"core.compatibility.enabled",
val minimumSignalQuality = preferences.createFlowPreference("core.signal.minimum", 0.25f)
val mainDeviceAddress = preferences.createFlowPreference<String?>("core.maindevice.address", null)
val mainDeviceModel = preferences.createFlowPreference("core.maindevice.model", PodDevice.Model.UNKNOWN, moshi)
val isOffloadedFilteringDisabled = preferences.createFlowPreference(
"core.compat.offloaded.filtering.disabled",
false
)
val showAll = preferences.createFlowPreference(
"core.showall.enabled",
false
)
val minimumSignalQuality = preferences.createFlowPreference(
"core.signal.minimum",
0.25f
)
val mainDeviceAddress = preferences.createFlowPreference<String?>(
"core.maindevice.address",
null
)
val mainDeviceModel = preferences.createFlowPreference<PodDevice.Model>(
"core.maindevice.model",
PodDevice.Model.UNKNOWN,
moshi
)
val isOffloadedBatchingDisabled = preferences.createFlowPreference("core.compat.offloaded.batching.disabled", false)
val useIndirectScanResultCallback = preferences.createFlowPreference("core.compat.indirectcallback.enabled", false)
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
monitorMode,
scannerMode,
compatibilityMode,
showAll,
minimumSignalQuality,
mainDeviceAddress,
isOffloadedFilteringDisabled,
isOffloadedBatchingDisabled,
useIndirectScanResultCallback,
debugSettings.isAutoReportingEnabled,
)
}
@@ -1,18 +0,0 @@
package eu.darken.capod.monitor.core
import dagger.BindsInstance
import dagger.hilt.DefineComponent
import dagger.hilt.components.SingletonComponent
@MonitorScope
@DefineComponent(parent = SingletonComponent::class)
interface MonitorComponent {
@DefineComponent.Builder
interface Builder {
fun coroutineScope(@BindsInstance coroutineScope: MonitorCoroutineScope): Builder
fun build(): MonitorComponent
}
}
@@ -1,16 +0,0 @@
package eu.darken.capod.monitor.core
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import kotlinx.coroutines.CoroutineScope
@InstallIn(MonitorComponent::class)
@Module()
abstract class ProcessorModule {
@Binds
@MonitorScope
abstract fun processorScope(scope: MonitorCoroutineScope): CoroutineScope
}
@@ -1,8 +0,0 @@
package eu.darken.capod.monitor.core
import javax.inject.Qualifier
@Qualifier
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class MonitorScope
@@ -4,8 +4,9 @@ import android.bluetooth.le.ScanFilter
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.DebugSettings
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.asLog
@@ -83,26 +84,50 @@ class PodMonitor @Inject constructor(
.setupCommonEventHandlers(TAG) { "mainDevice" }
.replayingShare(appScope)
private data class ScannerOptions(
val scannerMode: ScannerMode,
val showUnfiltered: Boolean,
val offloadedFilteringDisabled: Boolean,
val offloadedBatchingDisabled: Boolean,
val disableDirectCallback: Boolean,
)
private fun createBleScanner() = combine(
generalSettings.scannerMode.flow,
generalSettings.compatibilityMode.flow,
debugSettings.showUnfiltered.flow
) { scannerMode, compatMode, unfiltered ->
Triple(scannerMode, compatMode, unfiltered)
debugSettings.showUnfiltered.flow,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.isOffloadedFilteringDisabled.flow,
generalSettings.useIndirectScanResultCallback.flow,
) {
scannermode,
showUnfiltered,
isOffloadedBatchingDisabled,
isOffloadedFilteringDisabled,
useIndirectScanResultCallback,
->
ScannerOptions(
scannerMode = scannermode,
showUnfiltered = showUnfiltered,
offloadedFilteringDisabled = isOffloadedFilteringDisabled,
offloadedBatchingDisabled = isOffloadedBatchingDisabled,
disableDirectCallback = useIndirectScanResultCallback,
)
}
.flatMapLatest { (mode, compat, unfiltered) ->
.flatMapLatest { options ->
val filters = when {
unfiltered -> {
options.showUnfiltered -> {
log(TAG, WARN) { "Using unfiltered scan mode" }
setOf(getUnfilteredFilter())
setOf(ScanFilter.Builder().build())
}
else -> ProximityPairing.getBleScanFilter()
}
bleScanner.scan(
filters = filters,
scannerMode = mode,
compatMode = compat,
scannerMode = options.scannerMode,
disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
).map { preFilterAndMap(it) }
}
@@ -134,7 +159,7 @@ class PodMonitor @Inject constructor(
return pods
}
private suspend fun preFilterAndMap(rawResults: List<BleScanResult>): List<PodFactory.Result> = rawResults
private suspend fun preFilterAndMap(rawResults: Collection<BleScanResult>): List<PodFactory.Result> = rawResults
.groupBy { it.address }
.values
.map { sameAdrDevs ->
@@ -187,10 +212,6 @@ class PodMonitor @Inject constructor(
.also { log(TAG) { "Cached mainDevice is $it" } }
}
private fun getUnfilteredFilter(): ScanFilter {
return ScanFilter.Builder().build()
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor")
}
@@ -6,6 +6,9 @@ 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.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 kotlin.math.abs
@@ -31,14 +34,20 @@ interface PodDevice {
val rssi: Int
get() = scanResult.rssi
val confidence: Float
val reliability: Float
/**
* This is not correct but it works ¯\_(ツ)_/¯
* The range of the RSSI is device specific (ROMs).
*/
val signalQuality: Float
get() = ((100 - abs(rssi)) / 100f) * (max(BASE_CONFIDENCE, confidence))
get() {
/**
* This is not correct but it works ¯\_(ツ)_/¯
* The range of the RSSI is device specific (ROMs).
*/
val sqRssi = ((100 - abs(rssi)) / 100f)
val sqReliability = max(BASE_CONFIDENCE, reliability)
val sqAge = (Duration.between(seenFirstAt, Instant.now()).toMinutes().coerceAtMost(60) / 60f) * 0.25f
log(VERBOSE) { "Signal Quality ($address): rssi=$sqRssi, reliability=$reliability, age=$sqAge" }
return (sqRssi + sqReliability + sqAge) / 2f
}
val rawData: Map<Int, ByteArray>
get() = scanResult.manufacturerSpecificData
@@ -133,6 +142,6 @@ interface PodDevice {
}
companion object {
const val BASE_CONFIDENCE = 0.5f
const val BASE_CONFIDENCE = 0.0f
}
}
@@ -25,8 +25,8 @@ fun SinglePodDevice.getBatteryLevelHeadset(context: Context): String =
?: context.getString(R.string.general_value_not_available_label)
fun PodDevice.getSignalQuality(context: Context): String {
val percentage = 100 * signalQuality
return "~${percentage.roundToInt()}%"
val multiplier = 100 * signalQuality
return "${multiplier.roundToInt()}"
}
@DrawableRes
@@ -1,6 +1,7 @@
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
@@ -8,10 +9,10 @@ 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.airpods.AirPodsPro
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Duration
import java.time.Instant
import kotlin.math.max
abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
@@ -38,7 +39,8 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
val id: PodDevice.Id,
val seenFirstAt: Instant,
val seenCounter: Int,
val history: List<ApplePods>
val history: List<ApplePods>,
val lastCaseBattery: Float?,
) {
val lastMessage: ProximityPairing.Message
get() = history.last().proximityMessage
@@ -46,63 +48,81 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
val lastAddress: String
get() = history.last().address
val confidence: Float
get() = 0.75f + (history.size / (MAX_HISTORY * 4f))
val reliability: Float
get() {
if (history.size < 2) return 0f
fun averageRssi(latest: Int): Int =
history.map { it.rssi }.plus(latest).takeLast(10).median()
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
}
private fun List<Int>.median(): Int = this.sorted().let {
if (it.size % 2 == 0)
(it[it.size / 2] + it[(it.size - 1) / 2]) / 2
else
it[it.size / 2]
}
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object {
const val MAX_HISTORY = 20
// 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? = history
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, DualApplePods.LidState.CLOSED)
if (definitive.contains(basic.caseLidState)) return null
val definitive = setOf(
DualApplePods.LidState.OPEN,
DualApplePods.LidState.CLOSED,
DualApplePods.LidState.NOT_IN_CASE,
)
if (definitive.contains(basic.caseLidState)) return basic.caseLidState
return history
.filterIsInstance<AirPodsPro>() // TODO why is this AirPodsPro specific here?
.lastOrNull { it.caseLidState != DualApplePods.LidState.NOT_IN_CASE }
.takeLast(2)
.filterIsInstance<DualApplePods>()
.lastOrNull { it.caseLidState != DualApplePods.LidState.UNKNOWN }
?.caseLidState
?: DualApplePods.LidState.NOT_IN_CASE
}
open fun historyTrimmer(
pods: List<ApplePods>
): List<ApplePods> {
var trimmed = pods.takeLast(KnownDevice.MAX_HISTORY)
// We want to keep the case information
// If both pods are removed, and produce more history, we otherwise lose the case battery info.
val caseInfoFat = pods.lastOrNull { it is HasCase && it.batteryCasePercent != null }
val caseInfoTrimmed = trimmed.lastOrNull { it is HasCase && it.batteryCasePercent != null }
if (caseInfoFat != null && caseInfoTrimmed == null) {
trimmed = listOf(caseInfoFat) + trimmed.takeLast(KnownDevice.MAX_HISTORY - 1)
}
return trimmed
return pods.takeLast(KnownDevice.MAX_HISTORY)
}
internal open fun searchHistory(current: PodType): KnownDevice? {
@@ -110,7 +130,7 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
val message = current.proximityMessage
knownDevices.values.toList().forEach { knownDevice ->
if (knownDevice.isOlderThan(Duration.ofSeconds(20))) {
if (knownDevice.isOlderThan(Duration.ofSeconds(30))) {
log(tag, VERBOSE) { "searchHistory1: Removing stale known device: $knownDevice" }
knownDevices.remove(knownDevice.id)
}
@@ -146,18 +166,22 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
knownDevices[device.identifier] = when {
existing != null -> {
val history = existing.history.plus(device)
existing.copy(
seenCounter = existing.seenCounter + 1,
history = existing.history.plus(device)
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 = listOf(device)
history = history,
lastCaseBattery = history.determineLatestCaseBattery()
)
}
}
@@ -17,7 +17,7 @@ data class AirPodsGen1 constructor(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -55,9 +55,9 @@ data class AirPodsGen1 constructor(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
@@ -17,7 +17,7 @@ data class AirPodsGen2 constructor(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -54,9 +54,9 @@ data class AirPodsGen2 constructor(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
@@ -17,7 +17,7 @@ data class AirPodsGen3 constructor(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -54,9 +54,9 @@ data class AirPodsGen3 constructor(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
@@ -3,9 +3,11 @@ 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.HasAppleColor
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
@@ -19,23 +21,20 @@ data class AirPodsMax(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasEarDetection, HasStateDetectionAirPods {
) : SingleApplePods, HasEarDetection, HasChargeDetection, HasAppleColor {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
override val rssi: Int
get() = rssiAverage ?: super<SingleApplePods>.rssi
val isHeadphonesBeingWorn: Boolean
get() = rawStatus.isBitSet(1)
val isHeadsetBeingCharged: Boolean
override val isHeadsetBeingCharged: Boolean
get() = rawFlags.isBitSet(0)
override val isBeingWorn: Boolean
get() = isHeadphonesBeingWorn
get() = rawStatus.isBitSet(5)
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
@@ -57,8 +56,8 @@ data class AirPodsMax(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
reliability = result.reliability,
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
}
@@ -20,7 +20,7 @@ data class AirPodsPro(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
@@ -73,9 +73,9 @@ data class AirPodsPro(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
@@ -20,7 +20,7 @@ data class AirPodsPro2(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
@@ -73,9 +73,9 @@ data class AirPodsPro2(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
@@ -17,7 +17,7 @@ data class BeatsFitPro(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -54,9 +54,9 @@ data class BeatsFitPro(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
@@ -17,7 +17,7 @@ data class BeatsFlex(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -46,8 +46,8 @@ data class BeatsFlex(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
reliability = result.reliability,
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
@@ -17,7 +17,7 @@ data class BeatsSolo3(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -45,8 +45,8 @@ data class BeatsSolo3(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
reliability = result.reliability,
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
@@ -17,7 +17,7 @@ data class BeatsStudio3(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -43,8 +43,8 @@ data class BeatsStudio3(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
reliability = result.reliability,
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
}
@@ -17,7 +17,7 @@ data class BeatsX(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -46,8 +46,8 @@ data class BeatsX(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
reliability = result.reliability,
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
@@ -17,7 +17,7 @@ data class PowerBeats3(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -46,8 +46,8 @@ data class PowerBeats3(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
reliability = result.reliability,
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
}
@@ -17,7 +17,7 @@ data class PowerBeatsPro(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -54,9 +54,9 @@ data class PowerBeatsPro(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
@@ -25,7 +25,7 @@ data class FakeAirPodsGen1 constructor(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
@@ -138,9 +138,9 @@ data class FakeAirPodsGen1 constructor(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
}
@@ -23,7 +23,7 @@ data class FakeAirPodsGen2 constructor(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
@@ -136,9 +136,9 @@ data class FakeAirPodsGen2 constructor(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
}
@@ -25,7 +25,7 @@ data class FakeAirPodsGen3 constructor(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
@@ -138,9 +138,9 @@ data class FakeAirPodsGen3 constructor(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
@@ -27,7 +27,7 @@ data class FakeAirPodsPro constructor(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasChargeDetectionDual, HasEarDetectionDual, HasCase {
@@ -156,9 +156,9 @@ data class FakeAirPodsPro constructor(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
}
@@ -27,7 +27,7 @@ data class FakeAirPodsPro2 constructor(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null,
@@ -153,9 +153,9 @@ data class FakeAirPodsPro2 constructor(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
reliability = result.reliability,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
@@ -18,7 +18,7 @@ data class UnknownAppleDevice(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = 0f,
override val reliability: Float = 0f,
private val rssiAverage: Int? = null,
) : ApplePods {
@@ -49,8 +49,8 @@ data class UnknownAppleDevice(
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
reliability = result.reliability,
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
}
@@ -13,7 +13,7 @@ data class UnknownDevice(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val confidence: Float = 0f,
override val reliability: Float = 0f,
private val rssiAverage: Int? = null,
) : PodDevice {
@@ -34,8 +34,8 @@ class UnknownDeviceFactory @Inject constructor() {
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
reliability = result.confidence,
rssiAverage = result.rssiSmoothed(basic.rssi),
)
}
@@ -52,7 +52,7 @@ class UnknownDeviceFactory @Inject constructor() {
val confidence: Float
get() = 0.75f + (history.size / (MAX_HISTORY * 4f))
fun averageRssi(latest: Int): Int =
fun rssiSmoothed(latest: Int): Int =
history.map { it.rssi }.plus(latest).takeLast(10).median()
fun isOlderThan(age: Duration): Boolean {
@@ -70,7 +70,7 @@ class UnknownDeviceFactory @Inject constructor() {
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object {
const val MAX_HISTORY = 10
const val MAX_HISTORY = 20
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M6,4H18V5H21V7H18V9H21V11H18V13H21V15H18V17H21V19H18V20H6V19H3V17H6V15H3V13H6V11H3V9H6V7H3V5H6V4M11,15V18H12V15H11M13,15V18H14V15H13M15,15V18H16V15H15Z" />
</vector>
@@ -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="M22.77 19.32L21.7 18.5C21.72 18.33 21.74 18.17 21.74 18S21.73 17.67 21.7 17.5L22.76 16.68C22.85 16.6 22.88 16.47 22.82 16.36L21.82 14.63C21.76 14.5 21.63 14.5 21.5 14.5L20.27 15C20 14.82 19.73 14.65 19.42 14.53L19.23 13.21C19.22 13.09 19.11 13 19 13H17C16.87 13 16.76 13.09 16.74 13.21L16.55 14.53C16.25 14.66 15.96 14.82 15.7 15L14.46 14.5C14.35 14.5 14.22 14.5 14.15 14.63L13.15 16.36C13.09 16.47 13.11 16.6 13.21 16.68L14.27 17.5C14.25 17.67 14.24 17.83 14.24 18S14.25 18.33 14.27 18.5L13.21 19.32C13.12 19.4 13.09 19.53 13.15 19.64L14.15 21.37C14.21 21.5 14.34 21.5 14.46 21.5L15.7 21C15.96 21.18 16.24 21.35 16.55 21.47L16.74 22.79C16.76 22.91 16.86 23 17 23H19C19.11 23 19.22 22.91 19.24 22.79L19.43 21.47C19.73 21.34 20 21.18 20.27 21L21.5 21.5C21.63 21.5 21.76 21.5 21.83 21.37L22.83 19.64C22.89 19.53 22.86 19.4 22.77 19.32M18 19.5C17.16 19.5 16.5 18.83 16.5 18S17.17 16.5 18 16.5 19.5 17.17 19.5 18 18.83 19.5 18 19.5M17.62 3.22C17.43 3.08 17.22 3 17 3H3C2.78 3 2.57 3.08 2.38 3.22C1.95 3.56 1.87 4.19 2.21 4.62L7 10.75V15.87C6.96 16.16 7.06 16.47 7.29 16.7L11.3 20.71C11.4 20.81 11.5 20.88 11.65 20.93C11.22 20 11 19 11 18C11 16.17 11.72 14.41 13 13.1V10.75L17.79 4.62C18.13 4.19 18.05 3.56 17.62 3.22M11 10.05V17.58L9 15.58V10.06L5.04 5H14.96L11 10.05Z" />
</vector>
@@ -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="M5 5V19H7V21H3V3H7V5H5M20 7H7V9H20V7M20 11H7V13H20V11M20 15H7V17H20V15Z" />
</vector>
@@ -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="M6.91 5.5L9.21 7.79L7.79 9.21L5.5 6.91L3.21 9.21L1.79 7.79L4.09 5.5L1.79 3.21L3.21 1.79L5.5 4.09L7.79 1.79L9.21 3.21M22.21 16.21L20.79 14.79L18.5 17.09L16.21 14.79L14.79 16.21L17.09 18.5L14.79 20.79L16.21 22.21L18.5 19.91L20.79 22.21L22.21 20.79L19.91 18.5M20.4 6.83L17.18 11L15.6 9.73L16.77 8.23A9.08 9.08 0 0 0 10.11 13.85A4.5 4.5 0 1 1 7.5 13A4 4 0 0 1 8.28 13.08A11.27 11.27 0 0 1 16.43 6.26L15 5.18L16.27 3.6M10 17.5A2.5 2.5 0 1 0 7.5 20A2.5 2.5 0 0 0 10 17.5Z" />
</vector>
@@ -1,14 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">غير متوفر</string>
<string name="general_error_label">خطأ</string>
<string name="general_grant_permission_action">منح الإذن</string>
<string name="overview_nomaindevice_label">لا يوجد جهاز أساسي</string>
<string name="overview_nomaindevice_description">ِمن غير المحتمل أن تكون جميع الأجهزة المكشوفة مِلكك، شغل جهازك وقم بتوصيله أو اضبط الإعدادات.</string>
<string name="overview_bluetooth_disabled_label">تم تعطيل البلوتوت</string>
<string name="overview_bluetooth_disabled_description">تم تعطيل البلوتوت، قم بتفعيله ;)</string>
<string name="permission_bluetooth_connect_label">اتصال بلوتوث</string>
<string name="permission_bluetooth_connect_description">يتطلب هذا التطبيق إذن \"إتصال بلوتوث\" للتفاعل مع الأجهزة المقترنة وبدء الاتصالات.</string>
<string name="permission_bluetooth_scan_label">البحث عن بلوتوث</string>
<string name="permission_bluetooth_scan_description">يسمح إذن \"فحص البلوتوث\" لهذا التطبيق باكتشاف بيانات البلوتوث واستلامها من الأجهزة القريبة مثل AirPods.</string>
<string name="permission_bluetooth_label">بلوتوث</string>
<string name="permission_bluetooth_description">يتطلب هذا التطبيق إذن \"البلوتوت\" للاتصال بأجهزة البلوتوت المقترنة.</string>
<string name="permission_access_fine_location_label">الوصول إلى موقع جيد</string>
<string name="permission_access_fine_location_description">يستخدم CAPod إذن \"الموقع الدقيق\" لتلقي بيانات البلوتوث منخفضة الطاقة. تستخدم سماعات الرأس بلوتوث منخفض الطاقة لبث حالتها. لن يستخدم هذا التطبيق بيانات البلوتوث لتحديد موقعك.</string>
<string name="permission_background_location_label">الوصول إلى الموقع في الخلفية</string>
<string name="permission_background_location_description">يستخدم تطبيق CAPod \"الوصول إلى موقع الخلفية\" و\"الاتصال التلقائي\" أثناء إغلاق التطبيق. يتيح الوصول إلى الموقع في الخلفية التطبيق في تلقي بيانات البلوتوت منخفضة الطاقة أثناء وجودها في الخلفية. هذا التطبيق لن يستخدم بيانات البلوتوت لتحديد موقعك.</string>
<string name="permission_ignore_battery_optimizations_label">تعطيل خاصية تحسينات البطارية</string>
<string name="permission_ignore_battery_optimizations_description">تحسينات البطارية تمنع هذا التطبيق من تلقي بيانات البلوتوت بشكل موثوق أثناء وجود التطبيق في الخلفية.</string>
<string name="permission_required_title">الإذن التالي مطلوب:</string>
<string name="permission_system_alert_window_label">نافذة تنبيه النظام</string>
<string name="permission_system_alert_window_description">اسم لتطبيق CAPod بالظهور فوق التطبيقات الأخرى لتفعيل مِيزة \"الإشعارات المنبثقة\".</string>
<string name="settings_scanner_mode_lowpower_label">طاقة مُنخفضة</string>
<string name="settings_scanner_mode_balanced_label">متوازن</string>
<string name="settings_scanner_mode_lowlatency_label">وقت استجابة مُنخفض</string>
<string name="settings_monitor_mode_manual_label">عندما يُفتح التطبيق</string>
<string name="settings_monitor_mode_automatic_label">عندما يتم توصيل الجهاز</string>
<string name="settings_monitor_mode_always_label">دائمًا</string>
<string name="settings_reaction_autoconnect_whenseen_label">عندما يُنظر إليه</string>
<string name="settings_reaction_autoconnect_caseopen_label">العُلبة مفتوحة</string>
<string name="settings_reaction_autoconnect_inear_label">في الأذن</string>
<string name="pods_dual_left_label">السماعة اليسرى</string>
<string name="pods_dual_right_label">السماعة اليمنى</string>
<string name="pods_case_label">العلبة</string>
<string name="pods_case_status_open_label">فتح</string>
<string name="pods_case_status_closed_label">مُغلق</string>
<string name="pods_connection_state_disconnected_label">غير مُتصل بجهاز</string>
<string name="pods_connection_state_idle_label">مُتصل بجهاز، لكنه فارغ</string>
<string name="pods_connection_state_music_label">في وضع الموسيقى</string>
<string name="pods_connection_state_call_label">في وضع الاتصال</string>
<string name="pods_connection_state_ringing_label">الرنين</string>
<string name="pods_connection_state_hanging_up_label">إنهاء المكالمة</string>
<string name="pods_connection_state_unknown_label">حالة اتصال غير معروفة</string>
<string name="pods_unknown_raw_data_label">مُسودة بيانات</string>
<string name="pods_unknown_label">جهاز غير معروف</string>
<string name="pods_unknown_contact_dev">هذا جهاز غير معروف لكنه يستخدم صيغة رسالة مشابهة. دعنا نضيف دعما لها. اتصل بي :)</string>
<string name="pods_none_label_short">لا يوجد جهاز</string>
<string name="pods_charging_label">في حالة الشحن</string>
<string name="pods_inear_label">في الأذن</string>
<string name="pods_microphone_label">ميكروفون</string>
<string name="pods_yours">الخاصة بك</string>
<string name="headset_being_worn_label">يتم ارتداؤها</string>
<string name="pods_case_unknown_state">حالة غير معروفة</string>
<string name="last_seen_x">آخر ظهور: %s</string>
<string name="first_seen_x">أول ظهور:%s</string>
</resources>
+60 -1
View File
@@ -1,2 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"></resources>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_error_label">Erro</string>
<string name="general_grant_permission_action">Conceder permissão</string>
<string name="overview_nomaindevice_label">Sem dispositivo primário</string>
<string name="overview_nomaindevice_description">É improvável que os dispositivos detetados sejam seus. Ligue e conecte o seu dispositivo ou ajuste as definições.</string>
<string name="overview_bluetooth_disabled_label">O Bluetooth está desligado</string>
<string name="overview_bluetooth_disabled_description">O Bluetooth está desligado, ativa-o ;)</string>
<string name="permission_bluetooth_connect_label">Conexão ao Bluetooth</string>
<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>
<string name="permission_background_location_label">Acesso à localização de fundo</string>
<string name="permission_background_location_description">CAPod utiliza o acesso à localização de fundo para habilitar funcionalidades como \"Mostrar pop-up\" e \"AutoConectar\" enquanto a aplicação está fechada. Acesso à localização de fundo autoriza à aplicação receber dados de \'Bluetooth Low Energy\' enquanto está de fundo. Esta aplicação NÃO irá utilizar dados de Bluetooth para determinar a sua localização.</string>
<string name="permission_ignore_battery_optimizations_label">Desativar otimizações de bateria</string>
<string name="permission_ignore_battery_optimizations_description">Otimizações de bateria impedem que esta aplicação receba dados de Bluetooth fidedignos enquanto a aplicação está de fundo.</string>
<string name="permission_required_title">A seguinte permissão é necessária:</string>
<string name="permission_system_alert_window_label">Janela de Alerta do Sistema</string>
<string name="permission_system_alert_window_description">Autorizar CAPod para aparecer por cima de outras aplicações para tornar possível a funcionalidade \"Mostrar pop-up\".</string>
<string name="settings_scanner_mode_lowpower_label">Poupar bateria</string>
<string name="settings_scanner_mode_balanced_label">Equilibrado</string>
<string name="settings_scanner_mode_lowlatency_label">Baixa latência</string>
<string name="settings_monitor_mode_manual_label">Quando a aplicação está aberta</string>
<string name="settings_monitor_mode_automatic_label">Quando o dispositivo é conectado</string>
<string name="settings_monitor_mode_always_label">Sempre</string>
<string name="settings_reaction_autoconnect_whenseen_label">Quando visto</string>
<string name="settings_reaction_autoconnect_caseopen_label">Caixa está aberta</string>
<string name="settings_reaction_autoconnect_inear_label">No ouvido</string>
<string name="pods_dual_left_label">Pod esquerdo</string>
<string name="pods_dual_right_label">Pod direito</string>
<string name="pods_case_label">Caixa</string>
<string name="pods_case_status_open_label">Aberta</string>
<string name="pods_case_status_closed_label">Fechada</string>
<string name="pods_connection_state_disconnected_label">Não está conectado a um dispositivo</string>
<string name="pods_connection_state_idle_label">Conectado a um dispositivo, mas inativo</string>
<string name="pods_connection_state_music_label">Em modo de música</string>
<string name="pods_connection_state_call_label">Em modo de chamada</string>
<string name="pods_connection_state_ringing_label">A tocar</string>
<string name="pods_connection_state_hanging_up_label">A terminar a chamada</string>
<string name="pods_connection_state_unknown_label">Estado de conexão desconhecido</string>
<string name="pods_unknown_raw_data_label">Dados não tratados</string>
<string name="pods_unknown_label">Dispositivo desconhecido</string>
<string name="pods_unknown_contact_dev">Este é um dispositivo desconhecido, mas usa um formato de mensagem semelhante. Vamos adicionar suporte para isso, entre em contacto comigo :)</string>
<string name="pods_none_label_short">Sem dispositivo</string>
<string name="pods_charging_label">A carregar</string>
<string name="pods_inear_label">No ouvido</string>
<string name="pods_microphone_label">Microfone</string>
<string name="pods_yours">Seu</string>
<string name="headset_being_worn_label">A ser usado</string>
<string name="pods_case_unknown_state">Estado desconhecido</string>
<string name="last_seen_x">Última vez visto: %s</string>
<string name="first_seen_x">Primeira vez visto: %s</string>
</resources>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">Yok</string>
<string name="general_error_label">Hata</string>
<string name="general_grant_permission_action">İzin ver</string>
@@ -11,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Bu uygulama, eşleştirilmiş cihazlarla etkileşim kurmak ve bağlantıları başlatmak için \"Bluetooth bağlantısı\" izni gerektirir.</string>
<string name="permission_bluetooth_scan_label">Bluetooth taraması</string>
<string name="permission_bluetooth_scan_description">\"Bluetooth tarama\" izni, bu uygulamanın AirPods\'unuz gibi yakındaki cihazlardan Bluetooth verilerini keşfetmesine ve almasına izin verir.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Bu uygulama, eşleştirilmiş bluetooth cihazlarına bağlanmak için \"Bluetooth\" izni gerektirir.</string>
<string name="permission_access_fine_location_label">Hassas konuma erişin</string>
<string name="permission_access_fine_location_description">CAPod, Bluetooth Düşük Enerji verilerini almak için \"iyi konum\" iznini kullanır. Kulaklıklarınız durumlarını yayınlamak için Bluetooth Düşük Enerji teknolojisini kullanır. Bu uygulama konumunuzu belirlemek için Bluetooth verilerini KULLANMAYACAKTIR.</string>
@@ -6,6 +6,7 @@
<string name="overview_nomaindevice_label">Відсутній первинний пристрій</string>
<string name="overview_bluetooth_disabled_label">Bluetooth вимкнено</string>
<string name="overview_bluetooth_disabled_description">Bluetooth вимкнено, увімкніть його ;)</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_access_fine_location_label">Доступ до точного місцезнаходження</string>
<string name="permission_background_location_label">Доступ до місцезнаходження у тлі</string>
<string name="permission_ignore_battery_optimizations_label">Вимкнути оптимізацію батареї</string>
@@ -7,21 +7,21 @@
<string name="general_error_label">錯誤</string>
<string name="general_grant_permission_action">授予權限</string>
<string name="overview_nomaindevice_label">無主要裝置</string>
<string name="overview_nomaindevice_description">所有測到的裝置似乎並不是的。開啟並連線的裝置,也可以調整設定。</string>
<string name="overview_nomaindevice_description">所有測到的裝置似乎並不是的。開啟並連線的裝置,也可以調整設定。</string>
<string name="overview_bluetooth_disabled_label">藍牙已停用</string>
<string name="overview_bluetooth_disabled_description">藍牙已停用,啟用它 ;)</string>
<string name="permission_bluetooth_connect_label">藍牙連線</string>
<string name="permission_bluetooth_connect_description">應用程式需要「藍牙連線」權限以與已配對的裝置交互或配對新裝置。</string>
<string name="permission_bluetooth_connect_description">這個應用程式需要「藍牙連線」權限以與已配對的裝置交互或配對新裝置。</string>
<string name="permission_bluetooth_scan_label">藍牙掃描</string>
<string name="permission_bluetooth_scan_description">「藍牙掃描」權限允許應用程式發現附近的裝置並接收藍牙資料,的 AirPods 需要如此。</string>
<string name="permission_bluetooth_scan_description">「藍牙掃描」權限允許這個應用程式發現附近的裝置並接收藍牙資料,的 AirPods 需要如此。</string>
<string name="permission_bluetooth_label">藍牙</string>
<string name="permission_bluetooth_description">應用程式需要「藍牙」權限與已配對裝置連線。</string>
<string name="permission_bluetooth_description">這個應用程式需要「藍牙」權限與已配對裝置連線。</string>
<string name="permission_access_fine_location_label">存取精確位置</string>
<string name="permission_access_fine_location_description">CAPod 使用「精確位置」權限以接收低功耗藍牙資料。的耳機使用低功耗藍牙技術以廣播其狀態。這個應用程式不會使用藍牙資料來確定的位置。</string>
<string name="permission_access_fine_location_description">CAPod 使用「精確位置」權限以接收低功耗藍牙資料。的耳機使用低功耗藍牙技術以廣播其狀態。這個應用程式不會使用藍牙資料來確定的位置。</string>
<string name="permission_background_location_label">背景位置存取</string>
<string name="permission_background_location_description">CAPods 在應用程式關閉時使用「背景位置存取」來啟用諸如「顯示彈出式視窗」和「自動連線」等功能。背景位置存取允許應用程式在背景接收低功耗藍牙資料。這個應用程式不會使用藍牙資料來確定的位置。</string>
<string name="permission_background_location_description">CAPods 在應用程式關閉時使用「背景位置存取」來啟用諸如「顯示彈出式視窗」和「自動連線」等功能。背景位置存取允許這個應用程式在背景接收低功耗藍牙資料。這個應用程式不會使用藍牙資料來確定的位置。</string>
<string name="permission_ignore_battery_optimizations_label">停用電池效能最佳化</string>
<string name="permission_ignore_battery_optimizations_description">電池效能最佳化使應用程式在背景時無法可靠地接收藍牙資料。</string>
<string name="permission_ignore_battery_optimizations_description">電池效能最佳化使這個應用程式在背景時無法可靠地接收藍牙資料。</string>
<string name="permission_required_title">可能要求下列權限:</string>
<string name="permission_system_alert_window_label">系統警報視窗</string>
<string name="permission_system_alert_window_description">允許 CAPod 在其他應用程式上繪圖,使「顯示彈出式視窗」功能成為可能。</string>
@@ -53,7 +53,7 @@
<string name="pods_charging_label">充電中</string>
<string name="pods_inear_label">在耳中</string>
<string name="pods_microphone_label">麥克風</string>
<string name="pods_yours"></string>
<string name="pods_yours"></string>
<string name="headset_being_worn_label">正被佩戴</string>
<string name="pods_case_unknown_state">未知狀態</string>
<string name="last_seen_x">最後連線:%s</string>
@@ -1,6 +1,5 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@@ -21,15 +20,4 @@ class SingleApplePodsTest : BaseAirPodsTest() {
rawSuffix shouldBe 0x40.toUByte()
}
}
@Test
fun `test values based on AirPodMax`() = runTest {
create<AirPodsMax>("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") {
batteryHeadsetPercent shouldBe 0.5f
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
}
}
}
@@ -1,7 +1,9 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.isBitSet
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@@ -26,8 +28,6 @@ class AirPodsMaxTest : BaseAirPodsTest() {
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
model shouldBe PodDevice.Model.AIRPODS_MAX
}
}
@@ -49,8 +49,45 @@ class AirPodsMaxTest : BaseAirPodsTest() {
batteryHeadsetPercent shouldBe 0.5f
isHeadsetBeingCharged shouldBe false
}
}
isHeadphonesBeingWorn shouldBe true
@Test
fun `some dude at the gym`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 23 07 80 03 03 65 1F 28 32 D0 D9 71 43 00 9A 40 E7 6B EA 6C 2C FB") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
rawStatus shouldBe 0x23.toUByte()
rawPodsBattery shouldBe 0x07.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x03.toUByte()
rawDeviceColor shouldBe 0x03.toUByte()
rawSuffix shouldBe 0x65.toUByte()
batteryHeadsetPercent shouldBe 0.7f
isHeadsetBeingCharged shouldBe false
rawStatus.isBitSet(5) shouldBe true
podStyle shouldBe HasAppleColor.DeviceColor.BLUE
}
}
@Test
fun `wear status`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 03 07 80 03 03 65 1F 28 32 D0 D9 71 43 00 9A 40 E7 6B EA 6C 2C FB") {
rawStatus shouldBe 0x03.toUByte()
rawStatus.isBitSet(5) shouldBe false
isBeingWorn shouldBe false
}
create<AirPodsMax>("07 19 01 0A 20 23 07 80 03 03 65 1F 28 32 D0 D9 71 43 00 9A 40 E7 6B EA 6C 2C FB") {
rawStatus shouldBe 0x23.toUByte()
rawStatus.isBitSet(5) shouldBe true
isBeingWorn shouldBe true
}
}
}
+7 -9
View File
@@ -6,7 +6,6 @@ plugins {
}
apply(plugin = "dagger.hilt.android.plugin")
apply(plugin = "androidx.navigation.safeargs.kotlin")
apply(plugin = "com.bugsnag.android.gradle")
android {
compileSdk = ProjectConfig.compileSdk
@@ -24,10 +23,6 @@ android {
buildConfigField("String", "GITSHA", "\"${lastCommitHash()}\"")
buildConfigField("String", "BUILDTIME", "\"${buildTime()}\"")
manifestPlaceholders["bugsnagApiKey"] = getBugSnagApiKey(
File(System.getProperty("user.home"), ".appconfig/${ProjectConfig.packageName}/bugsnag.properties")
) ?: "fake"
}
signingConfigs {
@@ -49,9 +44,12 @@ android {
create("gplay") {
dimension = "version"
signingConfig = signingConfigs["releaseGplay"]
extra["useBugsnag"] = true
}
}
setupBugsnagPlugin()
buildTypes {
val customProguardRules = fileTree(File("../proguard")) {
include("*.pro")
@@ -92,7 +90,7 @@ android {
if (listOf("release", "beta").any { variantName.toLowerCase().contains(it) }) {
val outputFileName = ProjectConfig.packageName + "-WEAROS" +
"-v${defaultConfig.versionName}-${defaultConfig.versionCode}" +
"-${variantName.toUpperCase()}-${lastCommitHash()}.apk"
"-${variantName.toUpperCase()}.apk"
variantOutputImpl.outputFileName = outputFileName
}
@@ -138,9 +136,9 @@ dependencies {
addTesting()
implementation("com.bugsnag:bugsnag-android:5.9.2")
implementation("com.getkeepsafe.relinker:relinker:1.4.3")
implementation("androidx.wear:wear:1.2.0")
implementation("androidx.wear.tiles:tiles-material:1.1.0")
"gplayImplementation"("com.bugsnag:bugsnag-android:5.9.2")
"gplayImplementation"("com.getkeepsafe.relinker:relinker:1.4.3")
}
-24
View File
@@ -4,26 +4,6 @@
package="eu.darken.capod">
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-feature android:name="android.hardware.type.watch" />
@@ -59,10 +39,6 @@
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<meta-data
android:name="com.bugsnag.android.API_KEY"
android:value="${bugsnagApiKey}" />
</application>
</manifest>
@@ -3,11 +3,10 @@ package eu.darken.capod
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.*
import com.getkeepsafe.relinker.ReLinker
import dagger.hilt.android.HiltAndroidApp
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.autoreport.AutoReporting
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.*
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.main.core.GeneralSettings
@@ -20,7 +19,7 @@ import javax.inject.Inject
open class App : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var autoReporting: AutoReporting
@Inject lateinit var autoReporting: AutomaticBugReporter
@Inject lateinit var generalSettings: GeneralSettings
@Inject lateinit var workManager: WorkManager
@Inject @AppScope lateinit var appScope: CoroutineScope
@@ -29,11 +28,7 @@ open class App : Application(), Configuration.Provider {
super.onCreate()
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
ReLinker
.log { message -> log(TAG) { "ReLinker: $message" } }
.loadLibrary(this, "bugsnag-plugin-android-anr")
autoReporting.setup()
autoReporting.setup(this)
setupWorker()
@@ -6,8 +6,6 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.EntryPoints
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
@@ -15,9 +13,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.MonitorComponent
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.PodMonitor
import kotlinx.coroutines.*
@@ -31,22 +27,12 @@ import kotlinx.coroutines.flow.take
class MonitorWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted private val params: WorkerParameters,
monitorComponentBuilder: MonitorComponent.Builder,
private val dispatcherProvider: DispatcherProvider,
private val generalSettings: GeneralSettings,
private val permissionTool: PermissionTool,
private val podMonitor: PodMonitor,
private val bluetoothManager: BluetoothManager2,
) : CoroutineWorker(context, params) {
private val workerScope = MonitorCoroutineScope()
private val monitorComponent = monitorComponentBuilder
.coroutineScope(workerScope)
.build()
private val entryPoint by lazy {
EntryPoints.get(monitorComponent, MonitorWorkerEntryPoint::class.java)
}
private var finishedWithError = false
@@ -1,9 +0,0 @@
package eu.darken.capod.wear.core
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import eu.darken.capod.monitor.core.MonitorComponent
@InstallIn(MonitorComponent::class)
@EntryPoint
interface MonitorWorkerEntryPoint
@@ -5,7 +5,7 @@ import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.flow.combine
@@ -13,13 +13,9 @@ import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.PodDeviceCache
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.wear.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.wear.ui.overview.cards.MissingMainDeviceVH
@@ -38,11 +34,8 @@ class OverviewFragmentVM @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val podMonitor: PodMonitor,
private val permissionTool: PermissionTool,
private val generalSettings: GeneralSettings,
debugSettings: DebugSettings,
private val bluetoothManager: BluetoothManager2,
private val podDeviceCache: PodDeviceCache,
private val podFactory: PodFactory
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
private val updateTicker = channelFlow<Unit> {
@@ -54,30 +47,14 @@ class OverviewFragmentVM @Inject constructor(
val requestPermissionEvent = SingleLiveEvent<Permission>()
private val pods: Flow<List<PodDevice>> = permissionTool.missingPermissions
.flatMapLatest { permissions ->
if (permissions.isNotEmpty()) {
return@flatMapLatest flowOf(emptyList())
}
generalSettings.showAll.flow.flatMapLatest { showAll ->
if (showAll) {
podMonitor.devices
} else {
podMonitor.mainDevice.map { mainDevice ->
mainDevice?.let { listOf(it) } ?: emptyList()
}
}
}
}
.catch { errorEvents.postValue(it) }
private val mainDevice = podMonitor.mainDevice.throttleLatest(1000)
val listItems: LiveData<List<OverviewAdapter.Item>> = combine(
updateTicker,
permissionTool.missingPermissions,
debugSettings.isDebugModeEnabled.flow,
bluetoothManager.isBluetoothEnabled,
podMonitor.mainDevice.throttleLatest(1000),
mainDevice,
) { _, permissions, isDebugMode, isBluetoothEnabled, _ ->
val items = mutableListOf<OverviewAdapter.Item>()
+7 -9
View File
@@ -6,7 +6,6 @@ plugins {
}
apply(plugin = "dagger.hilt.android.plugin")
apply(plugin = "androidx.navigation.safeargs.kotlin")
apply(plugin = "com.bugsnag.android.gradle")
android {
@@ -22,10 +21,6 @@ android {
versionName = ProjectConfig.Version.name
testInstrumentationRunner = "eu.darken.capod.HiltTestRunner"
manifestPlaceholders["bugsnagApiKey"] = getBugSnagApiKey(
File(System.getProperty("user.home"), ".appconfig/${ProjectConfig.packageName}/bugsnag.properties")
) ?: "fake"
}
signingConfigs {
@@ -47,9 +42,12 @@ android {
create("gplay") {
dimension = "version"
signingConfig = signingConfigs["releaseGplay"]
extra["useBugsnag"] = true
}
}
setupBugsnagPlugin()
buildTypes {
val customProguardRules = fileTree(File(projectDir, "proguard")) {
include("*.pro")
@@ -90,7 +88,7 @@ android {
if (listOf("release", "beta").any { variantName.toLowerCase().contains(it) }) {
val outputFileName = ProjectConfig.packageName +
"-v${defaultConfig.versionName}-${defaultConfig.versionCode}" +
"-${variantName.toUpperCase()}-${lastCommitHash()}.apk"
"-${variantName.toUpperCase()}.apk"
variantOutputImpl.outputFileName = outputFileName
}
@@ -148,8 +146,8 @@ dependencies {
addTesting()
implementation("com.bugsnag:bugsnag-android:5.9.2")
implementation("com.getkeepsafe.relinker:relinker:1.4.3")
"gplayImplementation"("com.android.billingclient:billing:4.0.0")
"gplayImplementation"("com.bugsnag:bugsnag-android:5.9.2")
"gplayImplementation"("com.getkeepsafe.relinker:relinker:1.4.3")
}
+5 -1
View File
@@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="foss_upgrade_donate_label">تبرُّع</string>
<string name="foss_upgrade_alreadydonated_label">أنا متبرع بالفعل</string>
<string name="foss_upgrade_no_money_label">أُنفق كل أموالي على AirPods</string>
</resources>
+5 -1
View File
@@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="foss_upgrade_donate_label">Doar</string>
<string name="foss_upgrade_alreadydonated_label">Já doei</string>
<string name="foss_upgrade_no_money_label">Gastei todo o meu dinheiro em AirPods</string>
</resources>
@@ -14,15 +14,15 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Singleton
class BillingClientConnectionProvider @Inject constructor(
@@ -48,36 +48,40 @@ class BillingClientConnectionProvider @Inject constructor(
}
}.build()
val connectionResult = suspendCoroutine<BillingResult> { continuation ->
log(TAG, VERBOSE) { "startConnection(...)" }
client.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
log(TAG, VERBOSE) {
"onBillingSetupFinished(code=${result.responseCode}, message=${result.debugMessage})"
log(TAG, VERBOSE) { "startConnection(...)" }
client.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
log(TAG, VERBOSE) {
"onBillingSetupFinished(code=${result.responseCode}, message=${result.debugMessage})"
}
when (result.responseCode) {
BillingResponseCode.OK -> {
val connection = BillingClientConnection(client, purchasePublisher)
trySendBlocking(connection)
launch {
try {
purchasePublisher.value = connection.queryPurchases()
log(TAG) { "Initial IAP query successful." }
} catch (e: Exception) {
log(TAG, ERROR) { "Initial IAP query failed:\n${e.asLog()}" }
}
}
}
else -> {
close(BillingClientException(result))
}
continuation.resume(result)
}
}
override fun onBillingServiceDisconnected() {
log(TAG, VERBOSE) { "onBillingServiceDisconnected() " }
close(CancellationException("Billing service disconnected"))
}
})
}
val billingClientConnection = when (connectionResult.responseCode) {
BillingResponseCode.OK -> BillingClientConnection(client, purchasePublisher)
else -> throw BillingClientException(connectionResult)
}
try {
purchasePublisher.value = billingClientConnection.queryPurchases()
log(TAG) { "Initial IAP query successful." }
} catch (e: Exception) {
log(TAG, ERROR) { "Initial IAP query failed:\n${e.asLog()}" }
}
send(billingClientConnection)
override fun onBillingServiceDisconnected() {
log(TAG, VERBOSE) { "onBillingServiceDisconnected() " }
close(BillingException("Billing service disconnected"))
}
})
log(TAG) { "Awaiting close." }
awaitClose {
@@ -89,26 +93,28 @@ class BillingClientConnectionProvider @Inject constructor(
val connection: Flow<BillingClientConnection> = connectionProvider
.setupCommonEventHandlers(TAG) { "connection" }
.retryWhen { cause, attempt ->
log(TAG) { "Billing client connection error: ${cause.asLog()}" }
if (cause is CancellationException) {
log(TAG) { "BillingClient connection cancelled." }
return@retryWhen false
}
if (attempt > 5) {
log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" }
if (cause !is BillingException) {
log(TAG, WARN) { "Unknown exception type: $cause" }
return@retryWhen false
}
if (cause !is BillingClientException) {
log(TAG, WARN) { "Unknown BillingClient exception type: $cause" }
return@retryWhen false
} else {
log(TAG) { "BillingClient exception: $cause; ${cause.result}" }
}
if (cause.result.responseCode == BillingResponseCode.BILLING_UNAVAILABLE) {
if (cause is BillingClientException && cause.result.responseCode == BillingResponseCode.BILLING_UNAVAILABLE) {
log(TAG) { "Got BILLING_UNAVAILABLE while trying to connect client." }
return@retryWhen false
}
if (attempt > 5) {
log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" }
return@retryWhen false
}
log(TAG) { "Will retry BillingClient connection... *sigh*" }
delay(3000 * attempt)
true
@@ -2,9 +2,7 @@ package eu.darken.capod.common.upgrade.core.client
import com.android.billingclient.api.BillingResult
class BillingClientException(val result: BillingResult) : Exception() {
override val message: String?
get() = result.debugMessage
class BillingClientException(val result: BillingResult) : BillingException(result.debugMessage) {
override fun toString(): String =
"BillingClientException(code=${result.responseCode}, message=${result.debugMessage})"
@@ -0,0 +1,3 @@
package eu.darken.capod.common.upgrade.core.client
open class BillingException(override val message: String) : Exception()
+4 -1
View File
@@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متاحة.</string>
<string name="upgrades_no_purchases_found_check_account">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
</resources>
+4 -1
View File
@@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="upgrades_gplay_unavailable_error">Os serviços do Google Play estão indisponíveis.</string>
<string name="upgrades_no_purchases_found_check_account">Nenhuma compra encontrada. Está a usar a conta correta?</string>
</resources>
-25
View File
@@ -5,29 +5,8 @@
<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-feature
@@ -107,10 +86,6 @@
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<meta-data
android:name="com.bugsnag.android.API_KEY"
android:value="${bugsnagApiKey}" />
</application>
</manifest>
+3 -8
View File
@@ -3,10 +3,9 @@ package eu.darken.capod
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.getkeepsafe.relinker.ReLinker
import dagger.hilt.android.HiltAndroidApp
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.autoreport.AutoReporting
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.*
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.upgrade.UpgradeRepo
@@ -25,7 +24,7 @@ import javax.inject.Inject
open class App : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var autoReporting: AutoReporting
@Inject lateinit var autoReporting: AutomaticBugReporter
@Inject lateinit var monitorControl: MonitorControl
@Inject lateinit var podMonitor: PodMonitor
@Inject lateinit var widgetManager: WidgetManager
@@ -36,11 +35,7 @@ open class App : Application(), Configuration.Provider {
super.onCreate()
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
ReLinker
.log { message -> log(TAG) { "ReLinker: $message" } }
.loadLibrary(this, "bugsnag-plugin-android-anr")
autoReporting.setup()
autoReporting.setup(this)
log(TAG) { "onCreate() done! ${Exception().asLog()}" }
@@ -6,7 +6,7 @@ import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.flow.throttleLatest
@@ -42,8 +42,8 @@ class SinglePodsCardVH(parent: ViewGroup) :
// Charge state
device.apply {
if (this is HasChargeDetection) {
chargingIcon.isInvisible = isHeadsetBeingCharged
chargingLabel.isInvisible = isHeadsetBeingCharged
chargingIcon.isInvisible = !isHeadsetBeingCharged
chargingLabel.isInvisible = !isHeadsetBeingCharged
} else {
chargingIcon.isGone = true
chargingLabel.isGone = true
@@ -53,8 +53,8 @@ class SinglePodsCardVH(parent: ViewGroup) :
// Has ear detection
device.apply {
if (this is HasEarDetection) {
wearIcon.isInvisible = isBeingWorn
wearLabel.isInvisible = isBeingWorn
wearIcon.isInvisible = !isBeingWorn
wearLabel.isInvisible = !isBeingWorn
} else {
wearIcon.isGone = true
wearLabel.isGone = true
@@ -7,7 +7,7 @@ import androidx.fragment.app.viewModels
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.observe2
import eu.darken.capod.common.uix.PreferenceFragment3
import javax.inject.Inject
@@ -3,7 +3,7 @@ package eu.darken.capod.main.ui.settings.general.debug
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.recording.core.RecorderModule
@@ -22,6 +22,7 @@ import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.monitor.core.PodDeviceCache
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.*
import finish2
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
@@ -37,15 +38,13 @@ class WidgetProvider : AppWidgetProvider() {
@Inject lateinit var upgradeRepo: UpgradeRepo
@AppScope @Inject lateinit var appScope: CoroutineScope
private var asyncBarrier: PendingResult? = null
private fun executeAsync(
tag: String,
timeout: Duration = Duration.ofSeconds(10),
timeout: Duration = Duration.ofSeconds(7),
block: suspend () -> Unit
) {
val start = System.currentTimeMillis()
asyncBarrier = goAsync()
val asyncBarrier = goAsync()
log(TAG, VERBOSE) { "executeAsync($tag) starting asyncBarrier=$asyncBarrier " }
appScope.launch {
@@ -54,7 +53,7 @@ class WidgetProvider : AppWidgetProvider() {
} catch (e: Exception) {
log(TAG, ERROR) { "executeAsync($tag) failed: ${e.asLog()}" }
} finally {
asyncBarrier?.finish()
asyncBarrier.finish2()
val stop = System.currentTimeMillis()
log(TAG, VERBOSE) { "executeAsync($tag) DONE (${stop - start}ms) " }
}
@@ -25,9 +25,9 @@ class BluetoothEventReceiver : BroadcastReceiver() {
@Inject @AppScope lateinit var appScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
log { "onReceive($context, $intent)" }
log(TAG) { "onReceive($context, $intent)" }
if (!EXPECTED_ACTIONS.contains(intent.action)) {
log(WARN) { "Unknown action: $intent.action" }
log(TAG, WARN) { "Unknown action: ${intent.action}" }
return
}
@@ -41,7 +41,7 @@ class BluetoothEventReceiver : BroadcastReceiver() {
val supportedFeatures = ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
if (supportedFeatures.isEmpty()) {
log { "Device has no features we support." }
log(TAG) { "Device has no features we support." }
return
} else {
log { "Device has the following we features we support $supportedFeatures" }
@@ -49,7 +49,7 @@ class BluetoothEventReceiver : BroadcastReceiver() {
val pending = goAsync()
appScope.launch {
log { "Starting monitor" }
log(TAG) { "Starting monitor" }
monitorControl.startMonitor(bluetoothDevice, forceStart = false)
pending.finish()
}
@@ -5,10 +5,10 @@ import android.bluetooth.BluetoothDevice
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.EntryPoints
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
@@ -23,7 +23,6 @@ import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.monitor.core.MonitorComponent
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.ui.MonitorNotifications
@@ -39,7 +38,6 @@ import kotlinx.coroutines.flow.*
class MonitorWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted private val params: WorkerParameters,
monitorComponentBuilder: MonitorComponent.Builder,
private val dispatcherProvider: DispatcherProvider,
private val monitorNotifications: MonitorNotifications,
private val notificationManager: NotificationManager,
@@ -55,13 +53,6 @@ class MonitorWorker @AssistedInject constructor(
) : CoroutineWorker(context, params) {
private val workerScope = MonitorCoroutineScope()
private val monitorComponent = monitorComponentBuilder
.coroutineScope(workerScope)
.build()
private val entryPoint by lazy {
EntryPoints.get(monitorComponent, MonitorWorkerEntryPoint::class.java)
}
private var finishedWithError = false
@@ -69,10 +60,16 @@ class MonitorWorker @AssistedInject constructor(
log(TAG, VERBOSE) { "init(): workerId=$id" }
}
override suspend fun getForegroundInfo(): ForegroundInfo {
return monitorNotifications.getForegroundInfo(null)
}
override suspend fun doWork(): Result = try {
val start = System.currentTimeMillis()
log(TAG, VERBOSE) { "Executing $inputData now (runAttemptCount=$runAttemptCount)" }
setForeground(monitorNotifications.getForegroundInfo(null))
doDoWork()
val duration = System.currentTimeMillis() - start
@@ -101,7 +98,6 @@ class MonitorWorker @AssistedInject constructor(
val monitorJob = podMonitor.mainDevice
.setupCommonEventHandlers(TAG) { "PodMonitor" }
.onStart { setForeground(monitorNotifications.getForegroundInfo(null)) }
.distinctUntilChanged()
.throttleLatest(1000)
.onEach { currentDevice ->
@@ -1,9 +0,0 @@
package eu.darken.capod.monitor.core.worker
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import eu.darken.capod.monitor.core.MonitorComponent
@InstallIn(MonitorComponent::class)
@EntryPoint
interface MonitorWorkerEntryPoint
@@ -6,8 +6,6 @@ import android.widget.RemoteViews
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods
import javax.inject.Inject
@@ -16,12 +14,12 @@ class MonitorNotificationViewFactory @Inject constructor(
) {
fun createContentView(device: PodDevice): RemoteViews = when (device) {
is DualApplePods -> createDualApplePods(device)
is SingleApplePods -> createSingleApplePods(device)
is DualPodDevice -> createDualPods(device)
is SinglePodDevice -> createSinglePod(device)
else -> createUnknownDevice(device)
}
private fun createDualApplePods(device: DualApplePods): RemoteViews = RemoteViews(
private fun createDualPods(device: DualPodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_dual_pods_small
).apply {
@@ -29,23 +27,30 @@ class MonitorNotificationViewFactory @Inject constructor(
// Left
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
setTextViewText(R.id.pod_left_label, getBatteryLevelLeftPod(context))
val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
// Case
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
setTextViewText(R.id.pod_case_label, getBatteryLevelCase(context))
setViewVisibility(R.id.pod_case_charging, if (isCaseCharging) View.VISIBLE else View.GONE)
setViewVisibility(R.id.pod_case_charging, if (device is HasCase) View.VISIBLE else View.GONE)
(device as? HasCase)?.let { case ->
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
setTextViewText(R.id.pod_case_label, case.getBatteryLevelCase(context))
setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE)
}
// Right
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
setTextViewText(R.id.pod_right_label, getBatteryLevelRightPod(context))
val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
}
}
private fun createSingleApplePods(device: SingleApplePods): RemoteViews = RemoteViews(
private fun createSinglePod(device: SinglePodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_single_pods_small
).apply {
@@ -20,6 +20,8 @@ import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.pods.core.PodDevice
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
@@ -29,6 +31,7 @@ class MonitorNotifications @Inject constructor(
private val notificationViewFactory: MonitorNotificationViewFactory
) {
private val builderLock = Mutex()
private val builder: NotificationCompat.Builder
init {
@@ -56,7 +59,7 @@ class MonitorNotifications @Inject constructor(
}
}
fun getBuilder(device: PodDevice?): NotificationCompat.Builder {
private fun getBuilder(device: PodDevice?): NotificationCompat.Builder {
if (device == null) {
return builder.apply {
setCustomContentView(null)
@@ -76,9 +79,13 @@ class MonitorNotifications @Inject constructor(
}
}
fun getNotification(podDevice: PodDevice?): Notification = getBuilder(podDevice).build()
suspend fun getNotification(podDevice: PodDevice?): Notification = builderLock.withLock {
getBuilder(podDevice).build()
}
fun getForegroundInfo(podDevice: PodDevice?): ForegroundInfo = getBuilder(podDevice).toForegroundInfo()
suspend fun getForegroundInfo(podDevice: PodDevice?): ForegroundInfo = builderLock.withLock {
getBuilder(podDevice).toForegroundInfo()
}
@SuppressLint("InlinedApi")
private fun NotificationCompat.Builder.toForegroundInfo(): ForegroundInfo = if (hasApiLevel(29)) {
@@ -6,14 +6,13 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.databinding.PopupNotificationDualPodsBinding
import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods
import javax.inject.Inject
@@ -26,13 +25,13 @@ class PopUpPodViewFactory @Inject constructor(
private val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
fun createContentView(parent: ViewGroup, device: PodDevice): View = when (device) {
is DualApplePods -> createDualApplePods(parent, device)
is DualPodDevice -> createDualPods(parent, device)
// Unused, has no case to trigger reaction?
is SingleApplePods -> createSingleApplePods(parent, device)
is SinglePodDevice -> createSinglePod(parent, device)
else -> throw IllegalArgumentException("Unexpected device: $device")
}
private fun createDualApplePods(parent: ViewGroup, device: DualApplePods): View =
private fun createDualPods(parent: ViewGroup, device: DualPodDevice): View =
PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply {
device.apply {
podIcon.setImageResource(iconRes)
@@ -46,9 +45,12 @@ class PopUpPodViewFactory @Inject constructor(
podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
// Case
podCaseIcon.setImageResource(device.caseIcon)
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
podCaseBatteryLabel.text = getBatteryLevelCase(context)
podCaseContainer.isVisible = device is HasCase
(device as? HasCase)?.let { case ->
podCaseIcon.setImageResource(case.caseIcon)
podCaseBatteryIcon.setImageResource(getBatteryDrawable(case.batteryCasePercent))
podCaseBatteryLabel.text = case.getBatteryLevelCase(context)
}
// Right
podRightIcon.setImageResource(device.rightPodIcon)
@@ -57,7 +59,7 @@ class PopUpPodViewFactory @Inject constructor(
}
}.root
private fun createSingleApplePods(parent: ViewGroup, device: SingleApplePods): View =
private fun createSinglePod(parent: ViewGroup, device: SinglePodDevice): View =
PopupNotificationSinglePodsBinding.inflate(layoutInflater, parent, false).apply {
device.apply {
headphonesIcon.setImageResource(iconRes)
@@ -57,6 +57,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:id="@+id/pod_case_container"
android:orientation="horizontal">
<ImageView
@@ -7,9 +7,7 @@
<ImageView
android:id="@+id/pod_icon"
style="@style/PodInfoItemIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/PodInfoItemIcon.Popup"
android:src="@drawable/devic_earbuds_generic_both"
app:layout_constraintBottom_toBottomOf="@id/pod_label"
app:layout_constraintEnd_toStartOf="@id/pod_label"
@@ -55,9 +53,7 @@
<ImageView
android:id="@+id/pod_left_icon"
style="@style/PodInfoItemIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/PodInfoItemIcon.Popup"
android:layout_gravity="center"
android:src="@drawable/devic_airpods_gen1_left"
app:layout_constraintBottom_toBottomOf="@id/pod_left_battery_label"
@@ -67,9 +63,7 @@
<ImageView
android:id="@id/pod_left_battery_icon"
style="@style/PodInfoItemIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/PodInfoItemIcon.Popup"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/pod_left_battery_label"
app:layout_constraintEnd_toStartOf="@id/pod_left_battery_label"
@@ -103,9 +97,7 @@
<ImageView
android:id="@+id/pod_case_icon"
style="@style/PodInfoItemIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/PodInfoItemIcon.Popup"
android:layout_gravity="center"
android:src="@drawable/devic_airpods_gen1_case"
app:layout_constraintBottom_toBottomOf="@id/pod_case_battery_label"
@@ -115,9 +107,7 @@
<ImageView
android:id="@id/pod_case_battery_icon"
style="@style/PodInfoItemIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/PodInfoItemIcon.Popup"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/pod_case_battery_label"
app:layout_constraintEnd_toStartOf="@id/pod_case_battery_label"
@@ -150,9 +140,7 @@
<ImageView
android:id="@+id/pod_right_icon"
style="@style/PodInfoItemIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/PodInfoItemIcon.Popup"
android:layout_gravity="center"
android:src="@drawable/devic_airpods_gen1_right"
app:layout_constraintBottom_toBottomOf="@id/pod_right_battery_label"
@@ -162,9 +150,7 @@
<ImageView
android:id="@id/pod_right_battery_icon"
style="@style/PodInfoItemIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/PodInfoItemIcon.Popup"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/pod_right_battery_label"
app:layout_constraintEnd_toStartOf="@id/pod_right_battery_label"
@@ -7,9 +7,7 @@
<ImageView
android:id="@+id/headphones_icon"
android:layout_width="wrap_content"
style="@style/PodInfoItemIcon"
android:layout_height="wrap_content"
style="@style/PodInfoItemIcon.Popup"
android:src="@drawable/devic_earbuds_generic_both"
app:layout_constraintBottom_toBottomOf="@id/headphones_label"
app:layout_constraintEnd_toStartOf="@id/headphones_label"
@@ -56,9 +54,7 @@
<ImageView
android:id="@id/headphones_battery_icon"
android:layout_width="wrap_content"
style="@style/PodInfoItemIcon"
android:layout_height="wrap_content"
style="@style/PodInfoItemIcon.Popup"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/headphones_battery_label"
app:layout_constraintEnd_toStartOf="@id/headphones_battery_label"
+63
View File
@@ -7,10 +7,73 @@
<string name="general_upgrade_action">ترقية</string>
<string name="general_check_action">فحص</string>
<string name="general_close_action">إغلاق</string>
<string name="upgrade_capod_label">ترقية CAPod</string>
<string name="upgrade_capod_description">أُحصل على مِيزات إضافية وادعم المُطور.</string>
<string name="settings_monitor_mode_label">وضع المراقب</string>
<string name="settings_monitor_mode_description">في أي ظرف تريد أن يراقب فيه هذا التطبيق ببانات البلوتوت.</string>
<string name="settings_scanner_mode_label">وضع الماسح</string>
<string name="settings_scanner_mode_description">هل يجب على ماسح البيانات منخفض الطاقة التي تعمل بتقنية البلوتوت إعطاء الأولوية للأداء أم الحفاظ على الطاقة؟</string>
<string name="settings_autopause_label">الإيقاف التلقائي</string>
<string name="settings_autopause_description">إيقاف الصوت مؤقتًا عند إزالة الجهاز من أذنك.</string>
<string name="settings_showall_label">عرض كل الأجهزة</string>
<string name="settings_showall_description">عرض أجهزة الآخرين القريبة منك.</string>
<string name="settings_autopplay_label">التشغيل التلقائي</string>
<string name="settings_autoplay_description">بدأ تشغيل الصوت عند ارتداء الجهاز.</string>
<string name="settings_fake_data_label">بيانات مزيفة</string>
<string name="settings_fake_data_description">عرض بيانات مزيفة، مما يعني محاكاة أجهزة غير موجودة.</string>
<string name="settings_debug_label">إعدادات التصحيح</string>
<string name="settings_debug_description">إعدادات إضافية للمساعدة في استكشاف مشكلات التطبيق وإصلاحها.</string>
<string name="settings_signal_minimum_label">الحد الأدنى من جودة الإشارة</string>
<string name="settings_signal_minimum_description">الحد الأدنى من جودة الإشارة التي يجب أن يَعتبرها الجهاز لك.</string>
<string name="settings_autoconnect_label">الاتصال التلقائي</string>
<string name="settings_autoconnect_description">إذا لم يتصل الأندرويد تلقائيًا. فيمكننا أنة نطلبه أيضًا. سيؤدي هذا إلى ضبط إعداد وضع المراقب إلى \"دائما\".</string>
<string name="settings_autoconnect_condition_label">حالة الاتصال التلقائي</string>
<string name="settings_autoconnect_condition_description">متى يجب أن نحاول الاتصال بجهازك؟</string>
<string name="settings_reaction_label">التفاعل</string>
<string name="settings_reaction_description">التفاعل مع الأحداث والسلوكيات.</string>
<string name="settings_category_yourdevice_label">جهازك</string>
<string name="settings_maindevice_address_label">عنوان جهازك</string>
<string name="settings_maindevice_address_description">عنوان جهازك المقترن. يستخدم التطبيق هذا لتحديد متى يكون متصلا بهاتفك.</string>
<string name="settings_maindevice_address_none">غير موجود</string>
<string name="settings_maindevice_model_label">طِراز جهازك</string>
<string name="settings_maindevice_model_description">طِراز جهازك الرئيسي. يساعد هذا التطبيق في التعرف على جهازك عندما لا يكون متصلاً بهاتفك.</string>
<string name="settings_popup_caseopen_label">عرض إشعار منبثق</string>
<string name="settings_popup_caseopen_description">عرض إشعار منبثق عند فتح علبة الجهاز (تجريبي).</string>
<string name="settings_onepod_mode_label">وضعية السماعة الواحدة</string>
<string name="settings_onepod_mode_description">ليس بالضرورة ارتداء كِلتا السمَّاعتين، ارتداء سمَّاعة واحدة يكفي لتحريك ردود الفعل.</string>
<string name="notification_channel_device_status_label">حالة الجهاز</string>
<string name="debug_debuglog_size_label">الحجم</string>
<string name="debug_debuglog_size_compressed_label">الحجم المضغوط</string>
<string name="debug_notification_channel_label">اشعارات التصحيح</string>
<string name="debug_debuglog_file_label">ملف مسجل</string>
<string name="debug_debuglog_record_action">ملف التصحيح مسجل</string>
<string name="settings_support_installid_label">مُعرف التثبيت</string>
<string name="settings_support_installid_desc">تقارير الأخطاء والمشاكل التلقائية تكون مجهول المصدر. شارك مُعرف التثبيت الخاص بك إذا احتاج المطور إلى العثور على الأخطاء والمشاكل الخاصة بك.</string>
<string name="settings_support_label">الدعم</string>
<string name="settings_support_description">إن كنت بحاجة لبعض المساعدة.</string>
<string name="issue_tracker_label">تَعقب المشكلة أو الخطأ</string>
<string name="issue_tracker_description">أداة تعقب المشكلات العامة لتقارير الأخطاء وطلبات إضافة الميزات للتطبيق (باللغة الإنجليزية فقط).</string>
<string name="discord_label">ديسكورد</string>
<string name="discord_description">مكان لطرح الأسئلة.</string>
<string name="changelog_label">سِجل التغيير</string>
<string name="settings_label">الإعدادات</string>
<string name="settings_privacy_policy_label">سياسة الخصوصية</string>
<string name="settings_privacy_policy_desc">التعامل مع البيانات بمسؤولية.</string>
<string name="settings_licenses_label">التراخيص</string>
<string name="settings_category_other_label">أخرى</string>
<string name="settings_general_label">الإعدادات</string>
<string name="settings_general_description">التعديلات العامة التي تؤثر على التطبيق بِرُمَّته.</string>
<string name="settings_acknowledgements_label">شكر وتقدير</string>
<string name="settings_debug_autoreports_label">إرسال تقارير الأخطاء تلقائيًا</string>
<string name="settings_debug_autoreports_description">الإبلاغ عن المشكلات تلقائيًا. كمثال: تفاصيل حول تعطُّل التطبيق حتى أتمكن من معرفة كيفية إصلاحه.</string>
<string name="settings_debug_mode_label">وضع التصحيح</string>
<string name="settings_debug_mode_description">عرض معلومات إضافية لاستكشاف المشكلات وإصلاحها.</string>
<string name="settings_blescanner_unfiltered_label">بيانات BLE الغير المفلترة</string>
<string name="settings_blescanner_unfiltered_description">احذف أي عوامل فَلترة من الماسح BLE لإظهار جميع بيانات BLE التي يتم بثُّها. هذا مفيد في إضافة دعم لأنواع سماعات رأس جديدة.</string>
<string name="settings_compatibility_mode_label">وضع التوافق</string>
<string name="settings_compatibility_mode_description">تعطيل التحسينات لتحسين التوافق. جرب هذه الخاصية إذا كنت لا ترى أي بيانات.</string>
<string name="help_translate_label">التَّرْجَمَةً</string>
<string name="help_translate_description">ساعد في تَرْجَمَة هذا التطبيق إلى لغتك المفضلة.</string>
<string name="translators_thanks_title">المُترجمون</string>
<string name="translators_thanks_description">darken</string>
</resources>
+20 -20
View File
@@ -4,29 +4,29 @@
<string name="general_done_action">Fertig</string>
<string name="general_copy_action">Kopieren</string>
<string name="general_thank_you_label">Danke</string>
<string name="general_upgrade_action">Aktualisierung</string>
<string name="general_upgrade_action">Upgrade</string>
<string name="general_check_action">Überprüfen</string>
<string name="general_close_action">Schließen</string>
<string name="upgrade_capod_label">Verbesser CAPod</string>
<string name="upgrade_capod_description">Erhalten Sie zusätzliche Funktionen und unterstützen Sie den Entwickler.</string>
<string name="settings_monitor_mode_label">Überwachungsmodus</string>
<string name="settings_monitor_mode_description">Unter welchen Umständen überwacht diese App Bluetooth-Daten.</string>
<string name="settings_scanner_mode_label">Scannermodus</string>
<string name="settings_scanner_mode_description">Soll der Bluetooth Low Energy Datenscanner Leistung priorisieren oder Energie sparen?</string>
<string name="settings_scanner_mode_label">Scan-Modus</string>
<string name="settings_scanner_mode_description">Soll die Bluetooth Low Energy Überwachung Leistung priorisieren oder Energie sparen?</string>
<string name="settings_autopause_label">Automatische Pause</string>
<string name="settings_autopause_description">Halten Sie den Ton an, wenn Sie das Gerät von Ihrem Ohr entfernen.</string>
<string name="settings_autopause_description">Musik pausieren, wenn der Kopfhörer vom Ohr entfernt wird.</string>
<string name="settings_showall_label">Alle Geräte anzeigen</string>
<string name="settings_showall_description">Zeigen Sie die Geräte anderer Personen in Ihrer Nähe an.</string>
<string name="settings_autopplay_label">Automatisches Abspielen</string>
<string name="settings_autoplay_description">Starten Sie die Audiowiedergabe, wenn das Gerät getragen wird.</string>
<string name="settings_showall_description">Alle in der Nähe befindlichen Geräte anzeigen (auch die von anderen Personen).</string>
<string name="settings_autopplay_label">Automatisches abspielen</string>
<string name="settings_autoplay_description">Audiowiedergabe starten, wenn das Gerät getragen wird.</string>
<string name="settings_fake_data_label">Gefälschte Daten</string>
<string name="settings_fake_data_description">Falsche Daten anzeigen, also nicht existierende Geräte simulieren.</string>
<string name="settings_fake_data_description">Gefälschte Test-Daten anzeigen, d.h. nicht existierende Geräte simulieren.</string>
<string name="settings_debug_label">Debug-Einstellungen</string>
<string name="settings_debug_description">Zusätzliche Einstellungen zur Behebung von Problemen mit der App.</string>
<string name="settings_signal_minimum_label">Minimale Signalqualität</string>
<string name="settings_signal_minimum_description">Die minimale Signalqualität, die ein Gerät haben muss, muss als Ihre angesehen werden.</string>
<string name="settings_signal_minimum_description">Die minimale Signalqualität, die ein Gerät haben muss, damit als Ihres erkannt wird.</string>
<string name="settings_autoconnect_label">Automatisch verbinden</string>
<string name="settings_autoconnect_description">Wenn Android nicht automatisch eine Verbindung herstellt, können wir es auch fragen. Dadurch wird die Monitormodus-Einstellung auf „Immer“ gesetzt.</string>
<string name="settings_autoconnect_description">Wenn Android keine automatische Verbindung herstellt, können wir es auch auslösen. Durch diese Option wird die Monitormodus-Einstellung auf „Immer“ gesetzt.</string>
<string name="settings_autoconnect_condition_label">Bedingung für automatische Verbindung</string>
<string name="settings_autoconnect_condition_description">Wann sollten wir versuchen, eine Verbindung zu Ihrem Gerät herzustellen?</string>
<string name="settings_reaction_label">Reaktionen</string>
@@ -36,21 +36,21 @@
<string name="settings_maindevice_address_description">Die Adresse Ihres gekoppelten Geräts. Die App verwendet dies, um festzustellen, wann sie mit Ihrem Telefon verbunden ist.</string>
<string name="settings_maindevice_address_none">Keine</string>
<string name="settings_maindevice_model_label">Ihr Gerätemodell</string>
<string name="settings_maindevice_model_description">Das Modell Ihres Hauptgeräts. Dies hilft der App, Ihr Gerät zu erkennen, wenn es nicht mit Ihrem Telefon verbunden ist.</string>
<string name="settings_maindevice_model_description">Das Modell Ihres Hauptgeräts. Dies hilft der App, Ihr Gerät zu erkennen, wenn es nicht mit Ihrem Telefon verbunden ist.</string>
<string name="settings_popup_caseopen_label">Popup zeigen</string>
<string name="settings_popup_caseopen_description">Ein Popup anzeigen, wenn das Gerätegehäuse geöffnet wird (experimentell).</string>
<string name="settings_onepod_mode_label">Ein-Pod-Modus</string>
<string name="settings_onepod_mode_description">Das Tragen beider Pods ist nicht erforderlich, das Tragen eines einzigen Pods reicht aus, um Reaktionen auszulösen.</string>
<string name="settings_onepod_mode_description">Das Tragen beider Pods ist nicht erforderlich. Das Tragen eines einzigen Pods reicht aus, um Reaktionen auszulösen.</string>
<string name="notification_channel_device_status_label">Geräte Status</string>
<string name="debug_debuglog_size_label">Größe</string>
<string name="debug_debuglog_size_compressed_label">Komprimierte Größe</string>
<string name="debug_notification_channel_label">Debug Benachrichtigungen</string>
<string name="debug_debuglog_file_label">Aufgenommene Log-Datei</string>
<string name="debug_debuglog_record_action">Debug Log aufnehmen</string>
<string name="settings_support_installid_label">ID installieren</string>
<string name="settings_support_installid_desc">Automatische Fehlermeldungen sind anonym. Teilen Sie Ihre Installations- ID mit, wenn der Entwickler Ihre Fehlerberichte finden muss.</string>
<string name="settings_support_installid_label">Installations-ID</string>
<string name="settings_support_installid_desc">Automatische Fehlermeldungen sind anonym. Teilen Sie Ihre Installations- ID dem Entwickler mit, wenn er Ihre Fehlerberichte finden muss.</string>
<string name="settings_support_label">Unterstützung</string>
<string name="settings_support_description">Wenn Sie Hilfe brauchen.</string>
<string name="settings_support_description">Wenn du Hilfe brauchst.</string>
<string name="issue_tracker_label">Issue-Tracker</string>
<string name="issue_tracker_description">Ein öffentlicher Issue-Tracker für Fehlerberichte und Funktionsanfragen (nur auf Englisch).</string>
<string name="discord_label">Discord</string>
@@ -65,15 +65,15 @@
<string name="settings_general_description">Allgemeine Optimierungen, die die gesamte App betreffen.</string>
<string name="settings_acknowledgements_label">Danksagungen</string>
<string name="settings_debug_autoreports_label">Automatische Fehlerberichte</string>
<string name="settings_debug_autoreports_description">Meldet Probleme automatisch, Details zu einem App-Absturz, damit ich herausfinden kann, wie ich ihn beheben kann.</string>
<string name="settings_debug_autoreports_description">Meldet Probleme automatisch, z.B. Details zu einem App-Absturz, damit ich herausfinden kann, wie ich ihn beheben kann.</string>
<string name="settings_debug_mode_label">Debug-Modus</string>
<string name="settings_debug_mode_description">Zeigen Sie zusätzliche Informationen an, um Probleme zu beheben.</string>
<string name="settings_debug_mode_description">Zeige zusätzliche Informationen an, um Probleme zu beheben.</string>
<string name="settings_blescanner_unfiltered_label">Ungefilterte BLE-Daten</string>
<string name="settings_blescanner_unfiltered_description">Entfernen Sie alle Filter aus dem BLE-Scanner, um alle übertragenen BLE-Daten anzuzeigen. Nützlich, um Unterstützung für neue Kopfhörertypen hinzuzufügen.</string>
<string name="settings_blescanner_unfiltered_description">Entfernt alle Filter aus dem BLE-Scanner, um alle übertragenen BLE-Daten anzuzeigen. Nützlich, um Unterstützung für neue Kopfhörertypen hinzuzufügen.</string>
<string name="settings_compatibility_mode_label">Kompatibilitätsmodus</string>
<string name="settings_compatibility_mode_description">Deaktivieren Sie Optimierungen, um die Kompatibilität zu verbessern. Versuchen Sie dies, wenn Sie keine Daten sehen.</string>
<string name="settings_compatibility_mode_description">Deaktivieren Optimierungen, um die Kompatibilität zu verbessern. Probier diese Option, wenn gar keine Daten angezeigt werden.</string>
<string name="help_translate_label">Übersetzung</string>
<string name="help_translate_description">Helfen Sie mit, diese App in Ihre Lieblingssprache zu übersetzen.</string>
<string name="help_translate_description">Hilf mit diese App in deine Lieblingssprache zu übersetzen.</string>
<string name="translators_thanks_title">Übersetzer</string>
<string name="translators_thanks_description">Gamechanger181</string>
</resources>
+78 -1
View File
@@ -1,2 +1,79 @@
<?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_share_action">Partilhar</string>
<string name="general_done_action">OK</string>
<string name="general_copy_action">Copiar</string>
<string name="general_thank_you_label">Obrigado</string>
<string name="general_upgrade_action">Melhorar</string>
<string name="general_check_action">Verificar</string>
<string name="general_close_action">Fechar</string>
<string name="upgrade_capod_label">Melhorar CAPod</string>
<string name="upgrade_capod_description">Obtenha funcionalidades adicionais e apoie o programador.</string>
<string name="settings_monitor_mode_label">Modo de monitorização</string>
<string name="settings_monitor_mode_description">Sob quais circunstâncias esta aplicação monitora dados Bluetooth.</string>
<string name="settings_scanner_mode_label">Modo de scanner</string>
<string name="settings_scanner_mode_description">O scanner de dados \'Bluetooth Low Energy\' deve priorizar desempenho ou economizar energia?</string>
<string name="settings_autopause_label">Pausa automática</string>
<string name="settings_autopause_description">Pause o áudio ao remover o dispositivo do ouvido.</string>
<string name="settings_showall_label">Mostrar todos os dispositivos</string>
<string name="settings_showall_description">Mostrar os dispositivos de outras pessoas que estão perto de você.</string>
<string name="settings_autopplay_label">Reprodução automática</string>
<string name="settings_autoplay_description">Iniciar a reprodução de áudio quando o dispositivo for usado.</string>
<string name="settings_fake_data_label">Dados falsos</string>
<string name="settings_fake_data_description">Mostrar dados falsos, por exemplo, mostrar dispositivos que não existem.</string>
<string name="settings_debug_label">Definições de debug</string>
<string name="settings_debug_description">Definições adicionais para ajudar a resolver problemas com a aplicação.</string>
<string name="settings_signal_minimum_label">Qualidade de sinal mínima</string>
<string name="settings_signal_minimum_description">A qualidade de sinal mínima para o dispositivo ser considerado seu.</string>
<string name="settings_autoconnect_label">AutoConectar</string>
<string name="settings_autoconnect_description">Se o Android não conectar automaticamente, nós podemos pedir também. Isto irá alterar o modo de monitorização para \'Sempre\'.</string>
<string name="settings_autoconnect_condition_label">Condição para AutoConectar</string>
<string name="settings_autoconnect_condition_description">Quando é que devemos tentar conectar ao seu dispositivo?</string>
<string name="settings_reaction_label">Reações</string>
<string name="settings_reaction_description">Reagir a eventos e comportamentos.</string>
<string name="settings_category_yourdevice_label">O seu dispositivo</string>
<string name="settings_maindevice_address_label">Endereço do seu dispositivo</string>
<string name="settings_maindevice_address_description">O endereço do seu dispositivo emparelhado. A aplicação utiliza isto para determinar quando é que está conectado ao seu telemóvel.</string>
<string name="settings_maindevice_address_none">Nenhum</string>
<string name="settings_maindevice_model_label">Modelo do seu dispositivo</string>
<string name="settings_maindevice_model_description">O modelo do seu dispositivo principal. Isto ajuda a aplicação a reconhecer o seu dispositivo quando não está conectado ao seu telemóvel.</string>
<string name="settings_popup_caseopen_label">Mostrar pop-up</string>
<string name="settings_popup_caseopen_description">Mostrar pop-up quando a caixa do dispositivo está aberta (experimental).</string>
<string name="settings_onepod_mode_label">Modo de um pod</string>
<string name="settings_onepod_mode_description">Usar os dois pods não é necessário, usar um único pod é suficiente para desencadear reações.</string>
<string name="notification_channel_device_status_label">Estado do dispositivo</string>
<string name="debug_debuglog_size_label">Tamanho</string>
<string name="debug_debuglog_size_compressed_label">Tamanho comprimido</string>
<string name="debug_notification_channel_label">Notificações de debug</string>
<string name="debug_debuglog_file_label">Ficheiro de registo gravado</string>
<string name="debug_debuglog_record_action">Gravar ficheiro de registo</string>
<string name="settings_support_installid_label">ID de instalação</string>
<string name="settings_support_installid_desc">Os relatórios de erros automáticos são anónimos. Compartilhe o seu ID de instalação se o programador precisar encontrar os seus relatórios de erro.</string>
<string name="settings_support_label">Suporte</string>
<string name="settings_support_description">Se precisar de ajuda.</string>
<string name="issue_tracker_label">Rastreador de problemas</string>
<string name="issue_tracker_description">Um rastreador de problemas públicos para relatórios de erros e solicitações de recursos (somente em inglês).</string>
<string name="discord_label">Discord</string>
<string name="discord_description">Um lugar para conviver e tirar dúvidas.</string>
<string name="changelog_label">Registo de alterações</string>
<string name="settings_label">Definições</string>
<string name="settings_privacy_policy_label">Política de privacidade</string>
<string name="settings_privacy_policy_desc">Manuseio de dados com responsabilidade.</string>
<string name="settings_licenses_label">Licenças</string>
<string name="settings_category_other_label">Outro</string>
<string name="settings_general_label">Definições</string>
<string name="settings_general_description">Ajustes gerais que afetam a aplicação toda.</string>
<string name="settings_acknowledgements_label">Reconhecimentos</string>
<string name="settings_debug_autoreports_label">Relatar de erros automaticamente</string>
<string name="settings_debug_autoreports_description">Relata problemas automaticamente, por exemplo, detalhes sobre uma falha na aplicação para eu poder perceber como corrigi-lo.</string>
<string name="settings_debug_mode_label">Modo de debug</string>
<string name="settings_debug_mode_description">Mostrar informações adicionais para resolver problemas.</string>
<string name="settings_blescanner_unfiltered_label">Dados BLE sem filtro</string>
<string name="settings_blescanner_unfiltered_description">Remova qualquer filtro do scanner BLE para mostrar todos os dados BLE transmitidos. Útil para adicionar suporte para novos headphones.</string>
<string name="settings_compatibility_mode_label">Modo de compatibilidade</string>
<string name="settings_compatibility_mode_description">Desative as otimizações para melhorar a compatibilidade. Tente isso se não estiver a ver nenhum dado.</string>
<string name="help_translate_label">Tradução</string>
<string name="help_translate_description">Ajude a traduzir esta aplicação para o seu idioma favorito.</string>
<string name="translators_thanks_title">Tradutores</string>
<string name="translators_thanks_description">darken</string>
</resources>
+3
View File
@@ -53,6 +53,7 @@
<string name="settings_support_description">Yardıma ihtiyacın olursa.</string>
<string name="issue_tracker_label">Sorun izleyicisi</string>
<string name="issue_tracker_description">Hata raporları ve özellik istekleri için genel bir sorun izleyicisi (yalnızca İngilizce).</string>
<string name="discord_label">Discord</string>
<string name="discord_description">Takılıp soru sormak için bir yer.</string>
<string name="changelog_label">Değişiklik Günlüğü</string>
<string name="settings_label">Ayarlar</string>
@@ -73,4 +74,6 @@
<string name="settings_compatibility_mode_description">Uyumluluğu artırmak için iyileştirmeleri devre dışı bırakın. Herhangi bir veri görmüyorsanız bunu deneyin.</string>
<string name="help_translate_label">Tercüme</string>
<string name="help_translate_description">Bu uygulamayı en sevdiğiniz dile çevirmeye yardımcı olun.</string>
<string name="translators_thanks_title">Çevirmenler</string>
<string name="translators_thanks_description">karanlık</string>
</resources>
+11 -11
View File
@@ -23,20 +23,20 @@
<string name="settings_fake_data_description">顯示假資料,模擬不存在的裝置。</string>
<string name="settings_debug_label">偵錯設定</string>
<string name="settings_debug_description">一個能協助這個應用程式故障檢修的附加設定。</string>
<string name="settings_signal_minimum_label">最低訊號質</string>
<string name="settings_signal_minimum_description">可以被認為是的裝置的最低訊號質</string>
<string name="settings_signal_minimum_label">最低訊號</string>
<string name="settings_signal_minimum_description">可以被認為是的裝置的最低訊號質。</string>
<string name="settings_autoconnect_label">自動連線</string>
<string name="settings_autoconnect_description">如果 Android 不會自動連線,我們也可以要求它。這將把監視模式設定為「一律」。</string>
<string name="settings_autoconnect_condition_label">自動連線條件</string>
<string name="settings_autoconnect_condition_description">何時連線到的裝置?</string>
<string name="settings_autoconnect_condition_description">何時連線到的裝置?</string>
<string name="settings_reaction_label">反應</string>
<string name="settings_reaction_description">對事件和行為作出反應。</string>
<string name="settings_category_yourdevice_label">的裝置</string>
<string name="settings_maindevice_address_label">的裝置位址</string>
<string name="settings_maindevice_address_description">的已配對裝置的位址這個應用程式用位址來確定何時連線到的手機。</string>
<string name="settings_category_yourdevice_label">的裝置</string>
<string name="settings_maindevice_address_label">的裝置位址</string>
<string name="settings_maindevice_address_description">的已配對裝置的位址這個應用程式用位址來確定何時連線到的手機。</string>
<string name="settings_maindevice_address_none"></string>
<string name="settings_maindevice_model_label">的裝置型號</string>
<string name="settings_maindevice_model_description">的主要裝置型號這能助這個應用程式在未連線到的手機時辨識的裝置。</string>
<string name="settings_maindevice_model_label">的裝置型號</string>
<string name="settings_maindevice_model_description">的主要裝置型號這能助這個應用程式在未連線到的手機時辨識的裝置。</string>
<string name="settings_popup_caseopen_label">顯示彈出式視窗</string>
<string name="settings_popup_caseopen_description">充電盒開啟時顯示彈出式視窗 (實驗性)</string>
<string name="settings_onepod_mode_label">單耳模式</string>
@@ -48,9 +48,9 @@
<string name="debug_debuglog_file_label">已記錄的記錄檔</string>
<string name="debug_debuglog_record_action">記錄偵錯記錄</string>
<string name="settings_support_installid_label">安裝 ID</string>
<string name="settings_support_installid_desc">自動傳送的錯誤報告是匿名的。如果開發人員需要找出的錯誤報告將分享你的安裝 ID。</string>
<string name="settings_support_installid_desc">自動傳送的錯誤報告是匿名的。如果開發人員需要找出的錯誤報告將分享你的安裝 ID。</string>
<string name="settings_support_label">支援</string>
<string name="settings_support_description">如果需要協助。</string>
<string name="settings_support_description">如果需要協助。</string>
<string name="issue_tracker_label">問題追蹤器</string>
<string name="issue_tracker_description">一個用於錯誤回報和功能需求的公用問題追蹤器 (僅英文)。</string>
<string name="discord_label">Discord</string>
@@ -73,7 +73,7 @@
<string name="settings_compatibility_mode_label">相容模式</string>
<string name="settings_compatibility_mode_description">停用最佳化以提高相容性。請在未看到任何資料時開啟。</string>
<string name="help_translate_label">翻譯</string>
<string name="help_translate_description">協助翻譯為的最愛語言。</string>
<string name="help_translate_description">協助翻譯為的最愛語言。</string>
<string name="translators_thanks_title">翻譯人員</string>
<string name="translators_thanks_description">人工知能</string>
</resources>

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