Compare commits

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

* Remove unnecessary test
2023-03-13 13:47:32 +01:00
darken 708c7f38ec Merge remote-tracking branch 'origin/main' into main 2023-03-12 14:05:25 +01:00
darken 139daa1cc2 Update full gplay text 2023-03-12 14:05:20 +01:00
Matthias Urhahn b85ac778c0 Address that gplay api can emit multiple times (#114) 2023-03-12 02:58:37 +01:00
Matthias Urhahn a3e2c9e61d Bump dependencies and gradle (#113) 2023-03-11 18:16:24 +01:00
Matthias Urhahn ad78a0570c For some reason we can finish the widget update broadcast twice. (#112)
I can't yet determine why this race-condition occurs, due to the forced timeout, it shouldn't be due to exceeding the receiver ANR window...
2023-03-11 13:01:15 +01:00
Matthias Urhahn 7e6ee274d8 Quick data updates can cause concurrent access and modifying of the notification builder. (#111)
Internally building the notification can then throw a ConcurrentModificationException.
2023-03-11 13:01:05 +01:00
Matthias Urhahn daf7411c36 Show worker foreground notification early. (#110) 2023-03-11 13:00:55 +01:00
19 changed files with 98 additions and 72 deletions
+1 -1
View File
@@ -1 +1 @@
2.8.2-rc0 20802000 2.8.4-rc0 20804000
@@ -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
}
@@ -34,7 +34,7 @@ data class AirPodsMax(
get() = rawFlags.isBitSet(0) get() = rawFlags.isBitSet(0)
override val isBeingWorn: Boolean override val isBeingWorn: Boolean
get() = !rawStatus.isBitSet(5) get() = rawStatus.isBitSet(5)
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) { class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
@@ -1,6 +1,5 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -21,15 +20,4 @@ class SingleApplePodsTest : BaseAirPodsTest() {
rawSuffix shouldBe 0x40.toUByte() 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
isBeingWorn shouldBe true
}
}
} }
@@ -81,13 +81,13 @@ class AirPodsMaxTest : BaseAirPodsTest() {
rawStatus shouldBe 0x03.toUByte() rawStatus shouldBe 0x03.toUByte()
rawStatus.isBitSet(5) shouldBe false rawStatus.isBitSet(5) shouldBe false
isBeingWorn shouldBe true 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") { 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 shouldBe 0x23.toUByte()
rawStatus.isBitSet(5) shouldBe true rawStatus.isBitSet(5) shouldBe true
isBeingWorn shouldBe false isBeingWorn shouldBe true
} }
} }
} }
@@ -14,15 +14,15 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.flow.setupCommonEventHandlers
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Singleton @Singleton
class BillingClientConnectionProvider @Inject constructor( class BillingClientConnectionProvider @Inject constructor(
@@ -48,36 +48,40 @@ class BillingClientConnectionProvider @Inject constructor(
} }
}.build() }.build()
val connectionResult = suspendCoroutine<BillingResult> { continuation ->
log(TAG, VERBOSE) { "startConnection(...)" } log(TAG, VERBOSE) { "startConnection(...)" }
client.startConnection(object : BillingClientStateListener { client.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) { override fun onBillingSetupFinished(result: BillingResult) {
log(TAG, VERBOSE) { log(TAG, VERBOSE) {
"onBillingSetupFinished(code=${result.responseCode}, message=${result.debugMessage})" "onBillingSetupFinished(code=${result.responseCode}, message=${result.debugMessage})"
}
when (result.responseCode) {
BillingResponseCode.OK -> {
val connection = BillingClientConnection(client, purchasePublisher)
trySendBlocking(connection)
launch {
try {
purchasePublisher.value = connection.queryPurchases()
log(TAG) { "Initial IAP query successful." }
} catch (e: Exception) {
log(TAG, ERROR) { "Initial IAP query failed:\n${e.asLog()}" }
}
}
}
else -> {
close(BillingClientException(result))
} }
continuation.resume(result)
} }
}
override fun onBillingServiceDisconnected() { override fun onBillingServiceDisconnected() {
log(TAG, VERBOSE) { "onBillingServiceDisconnected() " } log(TAG, VERBOSE) { "onBillingServiceDisconnected() " }
close(CancellationException("Billing service disconnected")) close(BillingException("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)
log(TAG) { "Awaiting close." } log(TAG) { "Awaiting close." }
awaitClose { awaitClose {
@@ -89,26 +93,28 @@ class BillingClientConnectionProvider @Inject constructor(
val connection: Flow<BillingClientConnection> = connectionProvider val connection: Flow<BillingClientConnection> = connectionProvider
.setupCommonEventHandlers(TAG) { "connection" } .setupCommonEventHandlers(TAG) { "connection" }
.retryWhen { cause, attempt -> .retryWhen { cause, attempt ->
log(TAG) { "Billing client connection error: ${cause.asLog()}" }
if (cause is CancellationException) { if (cause is CancellationException) {
log(TAG) { "BillingClient connection cancelled." } log(TAG) { "BillingClient connection cancelled." }
return@retryWhen false 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 return@retryWhen false
} }
if (cause !is BillingClientException) {
log(TAG, WARN) { "Unknown BillingClient exception type: $cause" }
return@retryWhen false
} else {
log(TAG) { "BillingClient exception: $cause; ${cause.result}" }
}
if (cause.result.responseCode == BillingResponseCode.BILLING_UNAVAILABLE) { if (cause is BillingClientException && cause.result.responseCode == BillingResponseCode.BILLING_UNAVAILABLE) {
log(TAG) { "Got BILLING_UNAVAILABLE while trying to connect client." } log(TAG) { "Got BILLING_UNAVAILABLE while trying to connect client." }
return@retryWhen false 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*" } log(TAG) { "Will retry BillingClient connection... *sigh*" }
delay(3000 * attempt) delay(3000 * attempt)
true true
@@ -2,9 +2,7 @@ package eu.darken.capod.common.upgrade.core.client
import com.android.billingclient.api.BillingResult import com.android.billingclient.api.BillingResult
class BillingClientException(val result: BillingResult) : Exception() { class BillingClientException(val result: BillingResult) : BillingException(result.debugMessage) {
override val message: String?
get() = result.debugMessage
override fun toString(): String = override fun toString(): String =
"BillingClientException(code=${result.responseCode}, message=${result.debugMessage})" "BillingClientException(code=${result.responseCode}, message=${result.debugMessage})"
@@ -0,0 +1,3 @@
package eu.darken.capod.common.upgrade.core.client
open class BillingException(override val message: String) : Exception()
@@ -22,6 +22,7 @@ import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.monitor.core.PodDeviceCache import eu.darken.capod.monitor.core.PodDeviceCache
import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.* import eu.darken.capod.pods.core.*
import finish2
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
@@ -39,7 +40,7 @@ class WidgetProvider : AppWidgetProvider() {
private fun executeAsync( private fun executeAsync(
tag: String, tag: String,
timeout: Duration = Duration.ofSeconds(8), timeout: Duration = Duration.ofSeconds(7),
block: suspend () -> Unit block: suspend () -> Unit
) { ) {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
@@ -52,7 +53,7 @@ class WidgetProvider : AppWidgetProvider() {
} catch (e: Exception) { } catch (e: Exception) {
log(TAG, ERROR) { "executeAsync($tag) failed: ${e.asLog()}" } log(TAG, ERROR) { "executeAsync($tag) failed: ${e.asLog()}" }
} finally { } finally {
asyncBarrier.finish() asyncBarrier.finish2()
val stop = System.currentTimeMillis() val stop = System.currentTimeMillis()
log(TAG, VERBOSE) { "executeAsync($tag) DONE (${stop - start}ms) " } log(TAG, VERBOSE) { "executeAsync($tag) DONE (${stop - start}ms) " }
} }
@@ -5,6 +5,7 @@ import android.bluetooth.BluetoothDevice
import android.content.Context import android.content.Context
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@@ -59,10 +60,16 @@ class MonitorWorker @AssistedInject constructor(
log(TAG, VERBOSE) { "init(): workerId=$id" } log(TAG, VERBOSE) { "init(): workerId=$id" }
} }
override suspend fun getForegroundInfo(): ForegroundInfo {
return monitorNotifications.getForegroundInfo(null)
}
override suspend fun doWork(): Result = try { override suspend fun doWork(): Result = try {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
log(TAG, VERBOSE) { "Executing $inputData now (runAttemptCount=$runAttemptCount)" } log(TAG, VERBOSE) { "Executing $inputData now (runAttemptCount=$runAttemptCount)" }
setForeground(monitorNotifications.getForegroundInfo(null))
doDoWork() doDoWork()
val duration = System.currentTimeMillis() - start val duration = System.currentTimeMillis() - start
@@ -91,7 +98,6 @@ class MonitorWorker @AssistedInject constructor(
val monitorJob = podMonitor.mainDevice val monitorJob = podMonitor.mainDevice
.setupCommonEventHandlers(TAG) { "PodMonitor" } .setupCommonEventHandlers(TAG) { "PodMonitor" }
.onStart { setForeground(monitorNotifications.getForegroundInfo(null)) }
.distinctUntilChanged() .distinctUntilChanged()
.throttleLatest(1000) .throttleLatest(1000)
.onEach { currentDevice -> .onEach { currentDevice ->
@@ -20,6 +20,8 @@ import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.common.notifications.PendingIntentCompat import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject import javax.inject.Inject
@@ -29,6 +31,7 @@ class MonitorNotifications @Inject constructor(
private val notificationViewFactory: MonitorNotificationViewFactory private val notificationViewFactory: MonitorNotificationViewFactory
) { ) {
private val builderLock = Mutex()
private val builder: NotificationCompat.Builder private val builder: NotificationCompat.Builder
init { 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) { if (device == null) {
return builder.apply { return builder.apply {
setCustomContentView(null) 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") @SuppressLint("InlinedApi")
private fun NotificationCompat.Builder.toForegroundInfo(): ForegroundInfo = if (hasApiLevel(29)) { private fun NotificationCompat.Builder.toForegroundInfo(): ForegroundInfo = if (hasApiLevel(29)) {
+1 -1
View File
@@ -4,7 +4,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath("com.android.tools.build:gradle:7.2.2") classpath("com.android.tools.build:gradle:7.3.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.Kotlin.core}") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.Kotlin.core}")
classpath("com.google.dagger:hilt-android-gradle-plugin:${Versions.Dagger.core}") classpath("com.google.dagger:hilt-android-gradle-plugin:${Versions.Dagger.core}")
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:${Versions.AndroidX.Navigation.core}") classpath("androidx.navigation:navigation-safe-args-gradle-plugin:${Versions.AndroidX.Navigation.core}")
+2 -2
View File
@@ -8,7 +8,7 @@ repositories {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("com.android.tools.build:gradle:7.2.2") implementation("com.android.tools.build:gradle:7.3.1")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10") implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0")
implementation("com.squareup:javapoet:1.13.0") implementation("com.squareup:javapoet:1.13.0")
} }
+2 -2
View File
@@ -84,14 +84,14 @@ fun DependencyHandlerScope.addBaseWorkManager() {
} }
fun DependencyHandlerScope.addBaseAndroid() { fun DependencyHandlerScope.addBaseAndroid() {
implementation("androidx.core:core-ktx:1.8.0") implementation("androidx.core:core-ktx:1.10.0-rc01")
implementation("androidx.annotation:annotation:1.4.0") implementation("androidx.annotation:annotation:1.4.0")
implementation("androidx.collection:collection-ktx:1.2.0") implementation("androidx.collection:collection-ktx:1.2.0")
implementation("androidx.preference:preference-ktx:1.2.0") implementation("androidx.preference:preference-ktx:1.2.0")
} }
fun DependencyHandlerScope.addBaseAndroidUi() { fun DependencyHandlerScope.addBaseAndroidUi() {
implementation("androidx.appcompat:appcompat:1.6.0-alpha04") implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.constraintlayout:constraintlayout:2.1.3") implementation("androidx.constraintlayout:constraintlayout:2.1.3")
implementation("androidx.fragment:fragment-ktx:1.4.1") implementation("androidx.fragment:fragment-ktx:1.4.1")
+3 -3
View File
@@ -4,7 +4,7 @@ object Versions {
} }
object Kotlin { object Kotlin {
const val core = "1.7.10" const val core = "1.8.0"
const val coroutines = "1.6.2" const val coroutines = "1.6.2"
} }
@@ -13,11 +13,11 @@ object Versions {
} }
object Dagger { object Dagger {
const val core = "2.42" const val core = "2.45"
} }
object Moshi { object Moshi {
const val core = "1.13.0" const val core = "1.14.0"
} }
object AndroidX { object AndroidX {
@@ -0,0 +1,2 @@
Bugfixes and performance improvements.
¯\_(ツ)_/¯
@@ -0,0 +1,2 @@
Bugfixes and performance improvements.
¯\_(ツ)_/¯
@@ -10,6 +10,9 @@ Features:
* Automatically connect phone and AirPods. * Automatically connect phone and AirPods.
* Show popup when case is opened. * Show popup when case is opened.
CAPod has a Wear-OS version that shows the status of the pod device that is closest to you.
CAPod for Wear-OS is a stand-alone app and does not require a phone.
CAPod is ad-free. Some features require an in-app purchase. CAPod is ad-free. Some features require an in-app purchase.
Most popular AirPods and Beats devices are supported. Most popular AirPods and Beats devices are supported.
+1 -1
View File
@@ -1,6 +1,6 @@
### Updated by release.sh ### ### Updated by release.sh ###
project.versioning.major=2 project.versioning.major=2
project.versioning.minor=8 project.versioning.minor=8
project.versioning.patch=2 project.versioning.patch=4
project.versioning.build=0 project.versioning.build=0
############################# #############################