Compare commits

..
43 Commits
Author SHA1 Message Date
darken 71a060feb9 Release: 2.9.1-rc0 2023-04-05 16:19:46 +02:00
darken 25d643257c Update translations 2023-04-05 13:13:42 +02:00
darken 85900e5bff Improve final headphones checks and fix IllegalStateException if data changes between the final two checks. 2023-04-05 13:13:42 +02:00
darken d3257d6c5e Improve troubleshooting texts 2023-04-05 13:13:42 +02:00
darken afe1399f72 Release: 2.9.0-rc0 2023-04-02 22:25:17 +02:00
Matthias Urhahn 20ce2d3e22 A troubleshooter that automatically goes through different compatibility settings. (#125) 2023-04-02 22:22:15 +02:00
Matthias Urhahn cea8f2290d More IAP improvements (#121)
* More IAP improvements

* More refactoring

* More tolerance towards GPlay API issues.

* Bump billing dependency

* Migrate away from deprecated methods.
2023-03-20 08:21:54 +01:00
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
162 changed files with 1706 additions and 583 deletions
+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.7.2-rc0 20702000
2.9.1-rc0 20901000
+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
@@ -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
}
@@ -32,9 +32,9 @@ class BleScanner @Inject constructor(
@SuppressLint("MissingPermission") fun scan(
filters: Set<ScanFilter>,
scannerMode: ScannerMode = ScannerMode.BALANCED,
disableOffloadFiltering: Boolean = true,
disableOffloadBatching: Boolean = true,
disableDirectScanCallback: Boolean = true,
disableOffloadFiltering: Boolean = false,
disableOffloadBatching: Boolean = false,
disableDirectScanCallback: Boolean = false,
): Flow<Collection<BleScanResult>> = callbackFlow {
log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" }
@@ -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
@@ -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)
}
@@ -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
@@ -26,7 +26,7 @@ class GeneralSettings @Inject constructor(
val monitorMode = preferences.createFlowPreference("core.monitor.mode", MonitorMode.AUTOMATIC, moshi)
val scannerMode = preferences.createFlowPreference("core.scanner.mode", ScannerMode.BALANCED, moshi)
val showAll = preferences.createFlowPreference("core.showall.enabled", false)
val showAll = preferences.createFlowPreference("core.showall.enabled", true)
val minimumSignalQuality = preferences.createFlowPreference("core.signal.minimum", 0.25f)
@@ -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
@@ -6,7 +6,7 @@ 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
@@ -14,6 +14,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.PodDevice
@@ -113,6 +114,7 @@ class PodMonitor @Inject constructor(
disableDirectCallback = useIndirectScanResultCallback,
)
}
.throttleLatest(1000)
.flatMapLatest { options ->
val filters = when {
options.showUnfiltered -> {
@@ -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,6 +3,7 @@ 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
@@ -20,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, HasAppleColor {
) : SingleApplePods, HasEarDetection, HasChargeDetection, HasAppleColor {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
override val rssi: Int
get() = rssiAverage ?: super<SingleApplePods>.rssi
val isHeadphonesBeingWorn: Boolean
get() = rawStatus.isBitSet(1)
val isHeadsetBeingCharged: Boolean
override val isHeadsetBeingCharged: Boolean
get() = rawFlags.isBitSet(0)
override val isBeingWorn: Boolean
get() = isHeadphonesBeingWorn
get() = rawStatus.isBitSet(5)
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
@@ -58,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
}
}
@@ -58,4 +58,6 @@
<string name="pods_case_unknown_state">Невядомы стан</string>
<string name="last_seen_x">Апошні раз быў: %s</string>
<string name="first_seen_x">Упершыню быў: %s</string>
<string name="permission_post_notifications_label">Адлюстраванне апавяшчэнняў</string>
<string name="permission_post_notifications_description">"Дазволіць CAPod адлюстроўваць вам апавяшчэнні аб вашых AirPods: напрыклад, іх бягучы стан пры падключэнні."</string>
</resources>
@@ -58,4 +58,6 @@
<string name="pods_case_unknown_state">Estat desconegut</string>
<string name="last_seen_x">Últim cop vist: %s</string>
<string name="first_seen_x">Primera vegada vist: %s</string>
<string name="permission_post_notifications_label">Publica notificacions</string>
<string name="permission_post_notifications_description">"Permet que el CAPod us mostri notificacions sobre els vostres AirPods, p. ex. el seu estat actual mentre estan connectats."</string>
</resources>
@@ -58,4 +58,6 @@
<string name="pods_case_unknown_state">Neznámý stav</string>
<string name="last_seen_x">Naposledy spatřeno: %s</string>
<string name="first_seen_x">Poprvé spatřeno: %s</string>
<string name="permission_post_notifications_label">Posílat zprávy</string>
<string name="permission_post_notifications_description">"Povolte aplikaci CAPod zobrazovat oznámení o sluchátkách AirPods, např. o jejich aktuálním stavu během připojení."</string>
</resources>
@@ -7,7 +7,7 @@
<string name="general_error_label">Fehler</string>
<string name="general_grant_permission_action">Berechtigung erteilen</string>
<string name="overview_nomaindevice_label">Kein primäres Gerät</string>
<string name="overview_nomaindevice_description">Alle gefundenen Geräte sind unwahrscheinlich ihnen zu gehören. Verbinden sie ihr Gerät oder passen sie die Einstellungen an.</string>
<string name="overview_nomaindevice_description">Alle gefundenen Geräte gehören wahrscheinlich nicht dir. Schalte deine Kopfhörer ein oder passe die Einstellungen von CAPod an.</string>
<string name="overview_bluetooth_disabled_label">Bluetooth ist deaktiviert</string>
<string name="overview_bluetooth_disabled_description">Bluetooth ist deaktiviert, aktiviere es ;)</string>
<string name="permission_bluetooth_connect_label">Bluetooth verbinden</string>
@@ -58,4 +58,6 @@
<string name="pods_case_unknown_state">Unbekannter Zustand</string>
<string name="last_seen_x">Zuletzt gesehen: %s</string>
<string name="first_seen_x">Zum ersten Mal gesehen: %s</string>
<string name="permission_post_notifications_label">Benachrichtigungen posten</string>
<string name="permission_post_notifications_description">"Erlauben Sie CAPod, Ihnen Benachrichtigungen über Ihre AirPods anzuzeigen, z.b. ihren aktuellen Status, während sie verbunden sind."</string>
</resources>
@@ -1,5 +1,10 @@
<?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">Tasuline CAPod</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">Pole saadaval</string>
<string name="pods_dual_left_label">Vasak klapp</string>
<string name="pods_dual_right_label">Parem klapp</string>
<string name="permission_post_notifications_description">"Luba CAPodil näidata AirPodsi teateid, nt praegust olekut ühendatud olles."</string>
</resources>
@@ -55,4 +55,6 @@
<string name="pods_case_unknown_state">Keadaan tidak diketahui</string>
<string name="last_seen_x">Terakhir dilihat: %s</string>
<string name="first_seen_x">Pertama dilihat: %s</string>
<string name="permission_post_notifications_label">Siarkan pemberitahuan</string>
<string name="permission_post_notifications_description">"Benarkan CAPod menunjukkan pemberitahuan kepada anda tentang AirPods anda, mis. status semasa mereka semasa disambungkan."</string>
</resources>
@@ -58,4 +58,6 @@
<string name="pods_case_unknown_state">Onbekende staat</string>
<string name="last_seen_x">Laatst gezien: %s</string>
<string name="first_seen_x">Eerst gezien: %s</string>
<string name="permission_post_notifications_label">Post notificaties</string>
<string name="permission_post_notifications_description">"Sta CAPod toe notificaties over je AirPods te tonen, b.v. de huidige verbindingsstatus."</string>
</resources>
@@ -58,4 +58,6 @@
<string name="pods_case_unknown_state">Ukjent status</string>
<string name="last_seen_x">Sist sett: %s</string>
<string name="first_seen_x">Først sett: %s</string>
<string name="permission_post_notifications_label">Vis varsler</string>
<string name="permission_post_notifications_description">"La CAPod vise varsler om dine AirPods, f.eks. nåværende status under tilkobling."</string>
</resources>
@@ -58,4 +58,6 @@
<string name="pods_case_unknown_state">Nieznany stan</string>
<string name="last_seen_x">Ostatnio widziane: %s</string>
<string name="first_seen_x">Widziane pierwszy raz: %s</string>
<string name="permission_post_notifications_label">Wysyłanie powiadomień</string>
<string name="permission_post_notifications_description">"Zezwól CAPod na wyświetlanie powiadomień o słuchawkach AirPods, np. ich aktualnym statusie podczas połączenia."</string>
</resources>
@@ -2,6 +2,8 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">Fóssil CAPod</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_error_label">Erro</string>
<string name="general_grant_permission_action">Conceder permissão</string>
<string name="overview_nomaindevice_label">Nenhum dispositivo principal</string>
@@ -56,4 +58,6 @@
<string name="pods_case_unknown_state">Estado desconhecido</string>
<string name="last_seen_x">Visto pela última vez: %s</string>
<string name="first_seen_x">Visto pela primeira vez: %s</string>
<string name="permission_post_notifications_label">Post de notificações</string>
<string name="permission_post_notifications_description">"Permita que o CAPod mostre notificações sobre seus AirPods, por exemplo. seu status atual enquanto conectado."</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>
@@ -58,4 +58,5 @@
<string name="pods_case_unknown_state">Неизвестное состояние</string>
<string name="last_seen_x">Последние: %s</string>
<string name="first_seen_x">Первые: %s</string>
<string name="permission_post_notifications_description">"Разрешить CAPod показывать уведомления о ваших AirPods, например текущий статус при подключении."</string>
</resources>
@@ -58,4 +58,6 @@
<string name="pods_case_unknown_state">Neznámy stav</string>
<string name="last_seen_x">Naposledy videné: %s</string>
<string name="first_seen_x">Prvýkrát videné: %s</string>
<string name="permission_post_notifications_label">Zobraziť oznámenia</string>
<string name="permission_post_notifications_description">"Povoľte CAPod, aby vám zobrazoval upozornenia týkajúce sa vašich slúchadiel AirPods, napr. ich aktuálny stav počas pripojenia."</string>
</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>
@@ -3,11 +3,11 @@
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label"></string>
<string name="general_value_not_available_label"></string>
<string name="general_error_label">錯誤</string>
<string name="general_grant_permission_action">授予權限</string>
<string name="overview_nomaindevice_label">無主要裝置</string>
<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>
@@ -25,7 +25,7 @@
<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_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>
@@ -58,4 +58,6 @@
<string name="pods_case_unknown_state">未知狀態</string>
<string name="last_seen_x">最後連線:%s</string>
<string name="first_seen_x">首次連線:%s</string>
<string name="permission_post_notifications_label">張貼通知</string>
<string name="permission_post_notifications_description">"允許 CAPod 為您顯示關於您 AirPods 的通知,例如在連線時顯示它們的目前狀態。"</string>
</resources>
@@ -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,5 +1,6 @@
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
@@ -27,8 +28,6 @@ class AirPodsMaxTest : BaseAirPodsTest() {
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
model shouldBe PodDevice.Model.AIRPODS_MAX
}
}
@@ -50,8 +49,6 @@ class AirPodsMaxTest : BaseAirPodsTest() {
batteryHeadsetPercent shouldBe 0.5f
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
}
}
@@ -72,9 +69,25 @@ class AirPodsMaxTest : BaseAirPodsTest() {
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
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")
}
-4
View File
@@ -39,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>()
+8 -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,9 @@ dependencies {
addTesting()
implementation("com.bugsnag:bugsnag-android:5.9.2")
implementation("com.getkeepsafe.relinker:relinker:1.4.3")
"gplayImplementation"("com.android.billingclient:billing:5.1.0")
"gplayImplementation"("com.android.billingclient:billing-ktx:5.1.0")
"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">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>
@@ -1,13 +0,0 @@
package eu.darken.capod.common.upgrade.core
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.core.data.PurchasedSku
import eu.darken.capod.common.upgrade.core.data.Sku
fun Purchase.toPurchasedSku(): Collection<PurchasedSku> = skus.map {
PurchasedSku(Sku(it), this)
}
private val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Extensions")
@@ -49,7 +49,7 @@ class UpgradeRepoGplay @Inject constructor(
lastProStateAt = now
Info(billingData = data)
}
(now - lastProStateAt) < 6 * 60 * 1000L -> { // 6 hours
(now - lastProStateAt) < 6 * 60 * 60 * 1000L -> { // 6 hours
log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" }
Info(gracePeriod = true, billingData = null)
}
@@ -62,7 +62,7 @@ class UpgradeRepoGplay @Inject constructor(
// Ignore Google Play errors if the last pro state was recent
val now = System.currentTimeMillis()
log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, error=$it" }
if ((now - lastProStateAt) < 6 * 60 * 60 * 1000L) { // 6 hours
if ((now - lastProStateAt) < 24 * 60 * 60 * 1000L) { // 24 hours
log(TAG, VERBOSE) { "We are not pro, but were recently, and just and an error, what is GPlay doing???" }
emit(Info(gracePeriod = true, billingData = null))
} else {
@@ -123,7 +123,7 @@ class UpgradeRepoGplay @Inject constructor(
get() = UpgradeRepo.Type.GPLAY
override val isPro: Boolean
get() = billingData?.getProSku() != null
get() = billingData?.getProSku() != null || gracePeriod
override val upgradedAt: Instant?
get() = billingData
@@ -2,6 +2,7 @@ package eu.darken.capod.common.upgrade.core.client
import android.app.Activity
import com.android.billingclient.api.*
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
@@ -38,7 +39,7 @@ data class BillingClientConnection(
if (!result.isSuccess) {
log(TAG, WARN) { "queryPurchases() failed" }
throw BillingClientException(result)
throw BillingResultException(result)
} else {
requireNotNull(purchases)
}
@@ -47,34 +48,31 @@ data class BillingClientConnection(
return purchases
}
suspend fun acknowledgePurchase(purchase: Purchase): BillingResult {
suspend fun acknowledgePurchase(purchase: Purchase) {
val ack = AcknowledgePurchaseParams.newBuilder().apply {
setPurchaseToken(purchase.purchaseToken)
}.build()
val ackResult = suspendCoroutine<BillingResult> { continuation ->
val result = suspendCoroutine<BillingResult> { continuation ->
client.acknowledgePurchase(ack) { continuation.resume(it) }
}
log(TAG) {
"acknowledgePurchase(purchase=$purchase): code=${ackResult.responseCode}, message=${ackResult.debugMessage})"
}
if (!ackResult.isSuccess) {
throw BillingClientException(ackResult)
}
log(TAG, INFO) { "acknowledgePurchase($purchase): code=${result.responseCode} (${result.debugMessage})" }
return ackResult
if (!result.isSuccess) throw BillingResultException(result)
}
suspend fun querySku(sku: Sku): Sku.Details {
val skuParams = SkuDetailsParams.newBuilder().apply {
setType(BillingClient.SkuType.INAPP)
setSkusList(listOf(sku.id))
val productDetails = QueryProductDetailsParams.Product.newBuilder().apply {
setProductType(BillingClient.ProductType.INAPP)
setProductId(sku.id)
}.build()
val (result, details) = suspendCoroutine<Pair<BillingResult, Collection<SkuDetails>?>> { continuation ->
client.querySkuDetailsAsync(skuParams) { skuResult, skuDetails ->
continuation.resume(skuResult to skuDetails)
val params = QueryProductDetailsParams.newBuilder().setProductList(listOf(productDetails)).build()
val (result, details) = suspendCoroutine<Pair<BillingResult, Collection<ProductDetails>?>> { continuation ->
client.queryProductDetailsAsync(params) { result, skuDetails ->
continuation.resume(result to skuDetails)
}
}
@@ -82,7 +80,7 @@ data class BillingClientConnection(
"querySku(sku=$sku): code=${result.responseCode}, debug=${result.debugMessage}), skuDetails=$details"
}
if (!result.isSuccess) throw BillingClientException(result)
if (!result.isSuccess) throw BillingResultException(result)
if (details.isNullOrEmpty()) throw IllegalStateException("Unknown SKU, no details available.")
@@ -97,10 +95,16 @@ data class BillingClientConnection(
suspend fun launchBillingFlow(activity: Activity, skuDetails: Sku.Details): BillingResult {
log(TAG) { "launchBillingFlow(activity=$activity, skuDetails=$skuDetails)" }
return client.launchBillingFlow(
activity,
BillingFlowParams.newBuilder().setSkuDetails(skuDetails.details.single()).build()
)
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().apply {
setProductDetails(skuDetails.details.first())
}.build()
val billingFlowParams = BillingFlowParams.newBuilder().apply {
setProductDetailsParamsList(listOf(productParams))
}.build()
return client.launchBillingFlow(activity, billingFlowParams)
}
companion object {
@@ -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(BillingResultException(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 BillingResultException && cause.result.isGplayUnavailablePermanent) {
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
@@ -1,11 +0,0 @@
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
override fun toString(): String =
"BillingClientException(code=${result.responseCode}, message=${result.debugMessage})"
}
@@ -4,4 +4,14 @@ import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
internal val BillingResult.isSuccess: Boolean
get() = responseCode == BillingClient.BillingResponseCode.OK
get() = responseCode == BillingClient.BillingResponseCode.OK
internal val BillingResult.isGplayUnavailableTemporary: Boolean
get() = setOf(
BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
BillingClient.BillingResponseCode.SERVICE_TIMEOUT
).contains(responseCode)
internal val BillingResult.isGplayUnavailablePermanent: Boolean
get() = responseCode == BillingClient.BillingResponseCode.BILLING_UNAVAILABLE
@@ -0,0 +1,3 @@
package eu.darken.capod.common.upgrade.core.client
open class BillingException(override val message: String) : Exception()
@@ -0,0 +1,9 @@
package eu.darken.capod.common.upgrade.core.client
import com.android.billingclient.api.BillingResult
class BillingResultException(val result: BillingResult) : BillingException(result.debugMessage) {
override fun toString(): String =
"BillingResultException(code=${result.responseCode}, message=${result.debugMessage})"
}
@@ -7,13 +7,9 @@ import eu.darken.capod.common.error.LocalizedError
class GplayServiceUnavailableException(cause: Throwable) : Exception("Google Play services are unavailable.", cause),
HasLocalizedError {
override fun getLocalizedError(context: Context): LocalizedError {
return LocalizedError(
throwable = this,
label = "Google Play Services Unavailable",
description = context.getString(R.string.upgrades_gplay_unavailable_error)
)
}
override fun getLocalizedError(context: Context): LocalizedError = LocalizedError(
throwable = this,
label = "Google Play Services Unavailable",
description = context.getString(R.string.upgrades_gplay_unavailable_error)
)
}
@@ -1,11 +1,14 @@
package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.upgrade.core.toPurchasedSku
data class BillingData(
val purchases: Collection<Purchase>
) {
val purchasedSkus: Collection<PurchasedSku>
get() = purchases.map { it.toPurchasedSku() }.flatten()
private fun Purchase.toPurchasedSku(): Collection<PurchasedSku> = skus.map {
PurchasedSku(Sku(it), this)
}
}
@@ -1,7 +1,6 @@
package eu.darken.capod.common.upgrade.core.data
import android.app.Activity
import com.android.billingclient.api.BillingClient.BillingResponseCode
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.*
@@ -10,9 +9,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.upgrade.core.client.BillingClientConnectionProvider
import eu.darken.capod.common.upgrade.core.client.BillingClientException
import eu.darken.capod.common.upgrade.core.client.GplayServiceUnavailableException
import eu.darken.capod.common.upgrade.core.client.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
@@ -53,37 +50,34 @@ class BillingDataRepo @Inject constructor(
}
.forEach {
log(TAG, INFO) { "Acknowledging purchase: $it" }
try {
client.acknowledgePurchase(it)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to ancknowledge purchase: $it\n${e.asLog()}" }
}
client.acknowledgePurchase(it)
}
}
.setupCommonEventHandlers(TAG) { "connection-acks" }
.retryWhen { cause, attempt ->
log(TAG, ERROR) { "Failed to acknowledge purchase: ${cause.asLog()}" }
if (cause is CancellationException) {
log(TAG) { "Ack was cancelled (appScope?) cancelled." }
return@retryWhen false
}
if (attempt > 5) {
log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" }
return@retryWhen false
}
if (cause !is BillingClientException) {
log(TAG, WARN) { "Unknown BillingClient exception type: $cause" }
if (cause !is BillingException) {
log(TAG, WARN) { "Unknown exception type: $cause" }
return@retryWhen false
} else {
log(TAG) { "BillingClient exception: $cause; ${cause.result}" }
}
if (cause.result.responseCode == BillingResponseCode.BILLING_UNAVAILABLE) {
if (cause is BillingResultException && cause.result.isGplayUnavailablePermanent) {
log(TAG) { "Got BILLING_UNAVAILABLE while trying to ACK purchase." }
return@retryWhen false
}
log(TAG) { "Will retry ACK" }
log(TAG) { "Will retry ACK (attempt=$attempt)" }
delay(3000 * attempt)
true
}
@@ -108,7 +102,7 @@ class BillingDataRepo @Inject constructor(
} catch (e: Exception) {
log(TAG, WARN) { "Failed to start IAP flow:\n${e.asLog()}" }
val ignoredCodes = listOf(3, 6)
if (e !is BillingClientException || !e.result.responseCode.let { ignoredCodes.contains(it) }) {
if (e !is BillingResultException || !e.result.responseCode.let { ignoredCodes.contains(it) }) {
Bugs.report(TAG, "IAP flow failed for $sku", e)
}
@@ -119,16 +113,14 @@ class BillingDataRepo @Inject constructor(
companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "DataRepo")
internal fun Throwable.tryMapUserFriendly(): Throwable {
if (this !is BillingClientException) return this
return when (result.responseCode) {
BillingResponseCode.BILLING_UNAVAILABLE,
BillingResponseCode.SERVICE_UNAVAILABLE,
BillingResponseCode.SERVICE_DISCONNECTED,
BillingResponseCode.SERVICE_TIMEOUT -> GplayServiceUnavailableException(this)
else -> this
internal fun Throwable.tryMapUserFriendly(): Throwable = when {
this is BillingResultException && this.result.isGplayUnavailableTemporary -> {
GplayServiceUnavailableException(this)
}
this is BillingResultException && this.result.isGplayUnavailablePermanent -> {
GplayServiceUnavailableException(this)
}
else -> this
}
}
}
@@ -1,12 +1,12 @@
package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.SkuDetails
import com.android.billingclient.api.ProductDetails
data class Sku(
val id: String
) {
data class Details(
val sku: Sku,
val details: Collection<SkuDetails>,
val details: Collection<ProductDetails>,
)
}
+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>
+1 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="upgrades_gplay_unavailable_error">Google Play 服務不可用。</string>
<string name="upgrades_gplay_unavailable_error">Google Play 服務無法使用。</string>
<string name="upgrades_no_purchases_found_check_account">未找到訂單,確定帳戶無誤?</string>
</resources>
-4
View File
@@ -86,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
@@ -123,7 +123,9 @@ class OverviewFragmentVM @Inject constructor(
if (!isBluetoothEnabled) {
items.add(0, BluetoothDisabledVH.Item)
} else if (mainPod == null) {
items.add(0, MissingMainDeviceVH.Item)
items.add(0, MissingMainDeviceVH.Item {
OverviewFragmentDirections.actionOverviewFragmentToTroubleShooterFragment().navVia(this)
})
}
}
@@ -21,10 +21,12 @@ class MissingMainDeviceVH(parent: ViewGroup) :
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
troubleshootAction.setOnClickListener { item.onTroubleShoot() }
}
object Item : OverviewAdapter.Item {
data class Item(
val onTroubleShoot: () -> Unit,
) : OverviewAdapter.Item {
override val stableId: Long = Item::class.hashCode().toLong()
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
@@ -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) " }
}
@@ -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)

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