mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 11:16:12 -04:00
Compare commits
14
Commits
v2.8.2-rc0
...
v2.9.0-rc0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe1399f72 | ||
|
|
20ce2d3e22 | ||
|
|
cea8f2290d | ||
|
|
1b5b59c41b | ||
|
|
8df7c19e84 | ||
|
|
88b0069b94 | ||
|
|
3b8ddc9f06 | ||
|
|
708c7f38ec | ||
|
|
139daa1cc2 | ||
|
|
b85ac778c0 | ||
|
|
a3e2c9e61d | ||
|
|
ad78a0570c | ||
|
|
7e6ee274d8 | ||
|
|
daf7411c36 |
@@ -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(
|
@SuppressLint("MissingPermission") fun scan(
|
||||||
filters: Set<ScanFilter>,
|
filters: Set<ScanFilter>,
|
||||||
scannerMode: ScannerMode = ScannerMode.BALANCED,
|
scannerMode: ScannerMode = ScannerMode.BALANCED,
|
||||||
disableOffloadFiltering: Boolean = true,
|
disableOffloadFiltering: Boolean = false,
|
||||||
disableOffloadBatching: Boolean = true,
|
disableOffloadBatching: Boolean = false,
|
||||||
disableDirectScanCallback: Boolean = true,
|
disableDirectScanCallback: Boolean = false,
|
||||||
): Flow<Collection<BleScanResult>> = callbackFlow {
|
): Flow<Collection<BleScanResult>> = callbackFlow {
|
||||||
log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" }
|
log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" }
|
||||||
|
|
||||||
|
|||||||
@@ -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.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.replayingShare
|
import eu.darken.capod.common.flow.replayingShare
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
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.GeneralSettings
|
||||||
import eu.darken.capod.main.core.PermissionTool
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
@@ -113,6 +114,7 @@ class PodMonitor @Inject constructor(
|
|||||||
disableDirectCallback = useIndirectScanResultCallback,
|
disableDirectCallback = useIndirectScanResultCallback,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.throttleLatest(1000)
|
||||||
.flatMapLatest { options ->
|
.flatMapLatest { options ->
|
||||||
val filters = when {
|
val filters = when {
|
||||||
options.showUnfiltered -> {
|
options.showUnfiltered -> {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,8 @@ dependencies {
|
|||||||
|
|
||||||
addTesting()
|
addTesting()
|
||||||
|
|
||||||
"gplayImplementation"("com.android.billingclient:billing:4.0.0")
|
"gplayImplementation"("com.android.billingclient:billing:5.1.0")
|
||||||
|
"gplayImplementation"("com.android.billingclient:billing-ktx:5.1.0")
|
||||||
|
|
||||||
"gplayImplementation"("com.bugsnag:bugsnag-android:5.9.2")
|
"gplayImplementation"("com.bugsnag:bugsnag-android:5.9.2")
|
||||||
"gplayImplementation"("com.getkeepsafe.relinker:relinker:1.4.3")
|
"gplayImplementation"("com.getkeepsafe.relinker:relinker:1.4.3")
|
||||||
|
|||||||
@@ -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
|
lastProStateAt = now
|
||||||
Info(billingData = data)
|
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?" }
|
log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" }
|
||||||
Info(gracePeriod = true, billingData = null)
|
Info(gracePeriod = true, billingData = null)
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ class UpgradeRepoGplay @Inject constructor(
|
|||||||
// Ignore Google Play errors if the last pro state was recent
|
// Ignore Google Play errors if the last pro state was recent
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, error=$it" }
|
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???" }
|
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))
|
emit(Info(gracePeriod = true, billingData = null))
|
||||||
} else {
|
} else {
|
||||||
@@ -123,7 +123,7 @@ class UpgradeRepoGplay @Inject constructor(
|
|||||||
get() = UpgradeRepo.Type.GPLAY
|
get() = UpgradeRepo.Type.GPLAY
|
||||||
|
|
||||||
override val isPro: Boolean
|
override val isPro: Boolean
|
||||||
get() = billingData?.getProSku() != null
|
get() = billingData?.getProSku() != null || gracePeriod
|
||||||
|
|
||||||
override val upgradedAt: Instant?
|
override val upgradedAt: Instant?
|
||||||
get() = billingData
|
get() = billingData
|
||||||
|
|||||||
+25
-21
@@ -2,6 +2,7 @@ package eu.darken.capod.common.upgrade.core.client
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import com.android.billingclient.api.*
|
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.Logging.Priority.WARN
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
@@ -38,7 +39,7 @@ data class BillingClientConnection(
|
|||||||
|
|
||||||
if (!result.isSuccess) {
|
if (!result.isSuccess) {
|
||||||
log(TAG, WARN) { "queryPurchases() failed" }
|
log(TAG, WARN) { "queryPurchases() failed" }
|
||||||
throw BillingClientException(result)
|
throw BillingResultException(result)
|
||||||
} else {
|
} else {
|
||||||
requireNotNull(purchases)
|
requireNotNull(purchases)
|
||||||
}
|
}
|
||||||
@@ -47,34 +48,31 @@ data class BillingClientConnection(
|
|||||||
return purchases
|
return purchases
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun acknowledgePurchase(purchase: Purchase): BillingResult {
|
suspend fun acknowledgePurchase(purchase: Purchase) {
|
||||||
val ack = AcknowledgePurchaseParams.newBuilder().apply {
|
val ack = AcknowledgePurchaseParams.newBuilder().apply {
|
||||||
setPurchaseToken(purchase.purchaseToken)
|
setPurchaseToken(purchase.purchaseToken)
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
val ackResult = suspendCoroutine<BillingResult> { continuation ->
|
val result = suspendCoroutine<BillingResult> { continuation ->
|
||||||
client.acknowledgePurchase(ack) { continuation.resume(it) }
|
client.acknowledgePurchase(ack) { continuation.resume(it) }
|
||||||
}
|
}
|
||||||
log(TAG) {
|
|
||||||
"acknowledgePurchase(purchase=$purchase): code=${ackResult.responseCode}, message=${ackResult.debugMessage})"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ackResult.isSuccess) {
|
log(TAG, INFO) { "acknowledgePurchase($purchase): code=${result.responseCode} (${result.debugMessage})" }
|
||||||
throw BillingClientException(ackResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ackResult
|
if (!result.isSuccess) throw BillingResultException(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun querySku(sku: Sku): Sku.Details {
|
suspend fun querySku(sku: Sku): Sku.Details {
|
||||||
val skuParams = SkuDetailsParams.newBuilder().apply {
|
val productDetails = QueryProductDetailsParams.Product.newBuilder().apply {
|
||||||
setType(BillingClient.SkuType.INAPP)
|
setProductType(BillingClient.ProductType.INAPP)
|
||||||
setSkusList(listOf(sku.id))
|
setProductId(sku.id)
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
val (result, details) = suspendCoroutine<Pair<BillingResult, Collection<SkuDetails>?>> { continuation ->
|
val params = QueryProductDetailsParams.newBuilder().setProductList(listOf(productDetails)).build()
|
||||||
client.querySkuDetailsAsync(skuParams) { skuResult, skuDetails ->
|
|
||||||
continuation.resume(skuResult to skuDetails)
|
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"
|
"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.")
|
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 {
|
suspend fun launchBillingFlow(activity: Activity, skuDetails: Sku.Details): BillingResult {
|
||||||
log(TAG) { "launchBillingFlow(activity=$activity, skuDetails=$skuDetails)" }
|
log(TAG) { "launchBillingFlow(activity=$activity, skuDetails=$skuDetails)" }
|
||||||
return client.launchBillingFlow(
|
|
||||||
activity,
|
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().apply {
|
||||||
BillingFlowParams.newBuilder().setSkuDetails(skuDetails.details.single()).build()
|
setProductDetails(skuDetails.details.first())
|
||||||
)
|
}.build()
|
||||||
|
|
||||||
|
val billingFlowParams = BillingFlowParams.newBuilder().apply {
|
||||||
|
setProductDetailsParamsList(listOf(productParams))
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return client.launchBillingFlow(activity, billingFlowParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+44
-38
@@ -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(BillingResultException(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 BillingResultException && cause.result.isGplayUnavailablePermanent) {
|
||||||
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
|
||||||
|
|||||||
-11
@@ -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})"
|
|
||||||
}
|
|
||||||
+10
@@ -5,3 +5,13 @@ import com.android.billingclient.api.BillingResult
|
|||||||
|
|
||||||
internal val BillingResult.isSuccess: Boolean
|
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()
|
||||||
+9
@@ -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})"
|
||||||
|
}
|
||||||
+5
-9
@@ -7,13 +7,9 @@ import eu.darken.capod.common.error.LocalizedError
|
|||||||
|
|
||||||
class GplayServiceUnavailableException(cause: Throwable) : Exception("Google Play services are unavailable.", cause),
|
class GplayServiceUnavailableException(cause: Throwable) : Exception("Google Play services are unavailable.", cause),
|
||||||
HasLocalizedError {
|
HasLocalizedError {
|
||||||
override fun getLocalizedError(context: Context): LocalizedError {
|
override fun getLocalizedError(context: Context): LocalizedError = LocalizedError(
|
||||||
return LocalizedError(
|
throwable = this,
|
||||||
throwable = this,
|
label = "Google Play Services Unavailable",
|
||||||
label = "Google Play Services Unavailable",
|
description = context.getString(R.string.upgrades_gplay_unavailable_error)
|
||||||
description = context.getString(R.string.upgrades_gplay_unavailable_error)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package eu.darken.capod.common.upgrade.core.data
|
package eu.darken.capod.common.upgrade.core.data
|
||||||
|
|
||||||
import com.android.billingclient.api.Purchase
|
import com.android.billingclient.api.Purchase
|
||||||
import eu.darken.capod.common.upgrade.core.toPurchasedSku
|
|
||||||
|
|
||||||
data class BillingData(
|
data class BillingData(
|
||||||
val purchases: Collection<Purchase>
|
val purchases: Collection<Purchase>
|
||||||
) {
|
) {
|
||||||
val purchasedSkus: Collection<PurchasedSku>
|
val purchasedSkus: Collection<PurchasedSku>
|
||||||
get() = purchases.map { it.toPurchasedSku() }.flatten()
|
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
|
package eu.darken.capod.common.upgrade.core.data
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import com.android.billingclient.api.BillingClient.BillingResponseCode
|
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
import eu.darken.capod.common.debug.Bugs
|
import eu.darken.capod.common.debug.Bugs
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
||||||
@@ -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.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.replayingShare
|
import eu.darken.capod.common.flow.replayingShare
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||||
import eu.darken.capod.common.upgrade.core.client.BillingClientConnectionProvider
|
import eu.darken.capod.common.upgrade.core.client.*
|
||||||
import eu.darken.capod.common.upgrade.core.client.BillingClientException
|
|
||||||
import eu.darken.capod.common.upgrade.core.client.GplayServiceUnavailableException
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -53,37 +50,34 @@ class BillingDataRepo @Inject constructor(
|
|||||||
}
|
}
|
||||||
.forEach {
|
.forEach {
|
||||||
log(TAG, INFO) { "Acknowledging purchase: $it" }
|
log(TAG, INFO) { "Acknowledging purchase: $it" }
|
||||||
|
client.acknowledgePurchase(it)
|
||||||
try {
|
|
||||||
client.acknowledgePurchase(it)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log(TAG, ERROR) { "Failed to ancknowledge purchase: $it\n${e.asLog()}" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setupCommonEventHandlers(TAG) { "connection-acks" }
|
.setupCommonEventHandlers(TAG) { "connection-acks" }
|
||||||
.retryWhen { cause, attempt ->
|
.retryWhen { cause, attempt ->
|
||||||
|
log(TAG, ERROR) { "Failed to acknowledge purchase: ${cause.asLog()}" }
|
||||||
|
|
||||||
if (cause is CancellationException) {
|
if (cause is CancellationException) {
|
||||||
log(TAG) { "Ack was cancelled (appScope?) cancelled." }
|
log(TAG) { "Ack was cancelled (appScope?) cancelled." }
|
||||||
return@retryWhen false
|
return@retryWhen false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt > 5) {
|
if (attempt > 5) {
|
||||||
log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" }
|
log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" }
|
||||||
return@retryWhen false
|
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
|
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." }
|
log(TAG) { "Got BILLING_UNAVAILABLE while trying to ACK purchase." }
|
||||||
return@retryWhen false
|
return@retryWhen false
|
||||||
}
|
}
|
||||||
|
|
||||||
log(TAG) { "Will retry ACK" }
|
log(TAG) { "Will retry ACK (attempt=$attempt)" }
|
||||||
delay(3000 * attempt)
|
delay(3000 * attempt)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -108,7 +102,7 @@ class BillingDataRepo @Inject constructor(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, WARN) { "Failed to start IAP flow:\n${e.asLog()}" }
|
log(TAG, WARN) { "Failed to start IAP flow:\n${e.asLog()}" }
|
||||||
val ignoredCodes = listOf(3, 6)
|
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)
|
Bugs.report(TAG, "IAP flow failed for $sku", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,16 +113,14 @@ class BillingDataRepo @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "DataRepo")
|
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "DataRepo")
|
||||||
|
|
||||||
internal fun Throwable.tryMapUserFriendly(): Throwable {
|
internal fun Throwable.tryMapUserFriendly(): Throwable = when {
|
||||||
if (this !is BillingClientException) return this
|
this is BillingResultException && this.result.isGplayUnavailableTemporary -> {
|
||||||
|
GplayServiceUnavailableException(this)
|
||||||
return when (result.responseCode) {
|
|
||||||
BillingResponseCode.BILLING_UNAVAILABLE,
|
|
||||||
BillingResponseCode.SERVICE_UNAVAILABLE,
|
|
||||||
BillingResponseCode.SERVICE_DISCONNECTED,
|
|
||||||
BillingResponseCode.SERVICE_TIMEOUT -> GplayServiceUnavailableException(this)
|
|
||||||
else -> this
|
|
||||||
}
|
}
|
||||||
|
this is BillingResultException && this.result.isGplayUnavailablePermanent -> {
|
||||||
|
GplayServiceUnavailableException(this)
|
||||||
|
}
|
||||||
|
else -> this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package eu.darken.capod.common.upgrade.core.data
|
package eu.darken.capod.common.upgrade.core.data
|
||||||
|
|
||||||
import com.android.billingclient.api.SkuDetails
|
import com.android.billingclient.api.ProductDetails
|
||||||
|
|
||||||
data class Sku(
|
data class Sku(
|
||||||
val id: String
|
val id: String
|
||||||
) {
|
) {
|
||||||
data class Details(
|
data class Details(
|
||||||
val sku: Sku,
|
val sku: Sku,
|
||||||
val details: Collection<SkuDetails>,
|
val details: Collection<ProductDetails>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,9 @@ class OverviewFragmentVM @Inject constructor(
|
|||||||
if (!isBluetoothEnabled) {
|
if (!isBluetoothEnabled) {
|
||||||
items.add(0, BluetoothDisabledVH.Item)
|
items.add(0, BluetoothDisabledVH.Item)
|
||||||
} else if (mainPod == null) {
|
} 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,
|
item: Item,
|
||||||
payloads: List<Any>
|
payloads: List<Any>
|
||||||
) -> Unit = binding(payload = true) { item ->
|
) -> 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 stableId: Long = Item::class.hashCode().toLong()
|
||||||
|
|
||||||
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
|
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package eu.darken.capod.troubleshooter.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import androidx.navigation.ui.setupWithNavController
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.common.WebpageTool
|
||||||
|
import eu.darken.capod.common.navigation.popBackStack
|
||||||
|
import eu.darken.capod.common.uix.Fragment3
|
||||||
|
import eu.darken.capod.common.viewbinding.viewBinding
|
||||||
|
import eu.darken.capod.databinding.TroubleshooterFragmentBinding
|
||||||
|
import eu.darken.capod.troubleshooter.ui.TroubleShooterFragmentVM.BleState
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class TroubleShooterFragment : Fragment3(R.layout.troubleshooter_fragment) {
|
||||||
|
|
||||||
|
override val vm: TroubleShooterFragmentVM by viewModels()
|
||||||
|
override val ui: TroubleshooterFragmentBinding by viewBinding()
|
||||||
|
|
||||||
|
@Inject lateinit var webpageTool: WebpageTool
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
ui.toolbar.apply {
|
||||||
|
setupWithNavController(findNavController())
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.bleIntroStartAction.setOnClickListener { vm.troubleShootBle() }
|
||||||
|
|
||||||
|
vm.bleState.observe2(ui) { state ->
|
||||||
|
bleIntroContainer.isVisible = state is BleState.Intro
|
||||||
|
bleProcessContainer.isVisible = state is BleState.Working
|
||||||
|
bleResultContainer.isVisible = state is BleState.Result
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
is BleState.Intro -> {}
|
||||||
|
is BleState.Working -> {
|
||||||
|
bleProcessHistory.apply {
|
||||||
|
text = ""
|
||||||
|
state.allSteps.forEachIndexed { index, s -> append("#$index: $s\n") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is BleState.Result -> {
|
||||||
|
bleResultHistory.apply {
|
||||||
|
text = ""
|
||||||
|
state.history.forEachIndexed { index, s -> append("#$index: $s\n") }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
is BleState.Result.Success -> {
|
||||||
|
bleResultAction.isVisible = false
|
||||||
|
bleResultTitle.text = getString(R.string.troubleshooter_ble_result_success_title)
|
||||||
|
bleResultBody.text = getString(R.string.troubleshooter_ble_result_success_body)
|
||||||
|
bleResultAction.text = getString(R.string.general_close_action)
|
||||||
|
bleResultAction.setOnClickListener { popBackStack() }
|
||||||
|
}
|
||||||
|
is BleState.Result.Failure -> {
|
||||||
|
bleResultTitle.text = getString(R.string.troubleshooter_ble_result_failure_title)
|
||||||
|
bleResultBody.text = when (state.failureType) {
|
||||||
|
BleState.Result.Failure.Type.PHONE -> getString(R.string.troubleshooter_ble_result_failure_phone_body)
|
||||||
|
BleState.Result.Failure.Type.HEADPHONES -> getString(R.string.troubleshooter_ble_result_failure_phone_headphones)
|
||||||
|
}
|
||||||
|
bleResultAction.text = getString(R.string.general_check_action)
|
||||||
|
bleResultAction.setOnClickListener { vm.troubleShootBle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package eu.darken.capod.troubleshooter.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||||
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
|
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.uix.ViewModel3
|
||||||
|
import eu.darken.capod.main.core.GeneralSettings
|
||||||
|
import eu.darken.capod.monitor.core.PodMonitor
|
||||||
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
|
import eu.darken.capod.pods.core.unknown.UnknownDevice
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class TroubleShooterFragmentVM @Inject constructor(
|
||||||
|
@Suppress("UNUSED_PARAMETER") handle: SavedStateHandle,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
private val generalSettings: GeneralSettings,
|
||||||
|
private val podMonitor: PodMonitor,
|
||||||
|
private val debugSettings: DebugSettings,
|
||||||
|
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
|
||||||
|
|
||||||
|
private val _bleState = MutableStateFlow<BleState>(BleState.Intro())
|
||||||
|
val bleState = _bleState.asLiveData2()
|
||||||
|
|
||||||
|
init {
|
||||||
|
_bleState
|
||||||
|
.onEach { log(TAG) { "New BLE State: $it" } }
|
||||||
|
.launchIn(vmScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun progress(message: String) {
|
||||||
|
var state = _bleState.value
|
||||||
|
if (state !is BleState.Working) {
|
||||||
|
state = BleState.Working("Starting...")
|
||||||
|
}
|
||||||
|
|
||||||
|
_bleState.value = state.nextStep(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun success(message: String) {
|
||||||
|
var state = _bleState.value
|
||||||
|
if (state !is BleState.Working) {
|
||||||
|
state = BleState.Working("Starting...")
|
||||||
|
}
|
||||||
|
_bleState.value = state.toSuccess(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failure(message: String, type: BleState.Result.Failure.Type) {
|
||||||
|
var state = _bleState.value
|
||||||
|
if (state !is BleState.Working) {
|
||||||
|
state = BleState.Working("Starting...")
|
||||||
|
}
|
||||||
|
_bleState.value = state.toFailure(message, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun troubleShootBle() = launch(context = dispatcherProvider.IO) {
|
||||||
|
log(TAG) { "troubleShootBle()" }
|
||||||
|
|
||||||
|
generalSettings.scannerMode.value = ScannerMode.LOW_LATENCY
|
||||||
|
|
||||||
|
run {
|
||||||
|
progress("Checking for headphones...")
|
||||||
|
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||||
|
podMonitor.mainDevice.filterNotNull().firstOrNull()
|
||||||
|
}
|
||||||
|
if (mainDevice != null) {
|
||||||
|
success("Headphones found, nothing to troubleshoot.")
|
||||||
|
return@launch
|
||||||
|
} else {
|
||||||
|
progress("Headphones not detected.\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<PodDevice> = { hardwareFilteringDisabled,
|
||||||
|
hardwareBatchingDisabled,
|
||||||
|
indirectCallback,
|
||||||
|
unfiltered ->
|
||||||
|
val sb = StringBuilder("SCAN - Settings: ")
|
||||||
|
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
|
||||||
|
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
|
||||||
|
sb.append("indirectCallback=$indirectCallback, ")
|
||||||
|
sb.append("unfiltered=$unfiltered")
|
||||||
|
progress(sb.toString())
|
||||||
|
generalSettings.isOffloadedFilteringDisabled.value = hardwareFilteringDisabled
|
||||||
|
generalSettings.isOffloadedBatchingDisabled.value = hardwareBatchingDisabled
|
||||||
|
generalSettings.useIndirectScanResultCallback.value = indirectCallback
|
||||||
|
debugSettings.showUnfiltered.value = unfiltered
|
||||||
|
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
val devices = withTimeoutOrNull(STEP_TIME) {
|
||||||
|
podMonitor.devices
|
||||||
|
.take(10)
|
||||||
|
.takeWhile { System.currentTimeMillis() - start < STEP_TIME - 1000 }
|
||||||
|
.toList()
|
||||||
|
.flatten()
|
||||||
|
.distinctBy { it.address }
|
||||||
|
} ?: emptyList()
|
||||||
|
log(TAG) { "SCAN: BLE Devices: $devices" }
|
||||||
|
if (devices.isNotEmpty()) {
|
||||||
|
progress("SCAN: Received data from ${devices.size} BLE devices")
|
||||||
|
devices
|
||||||
|
} else {
|
||||||
|
progress("SCAN: No data received")
|
||||||
|
devices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
run {
|
||||||
|
progress("Checking if we can receive BLE data at all.")
|
||||||
|
if (doScan(false, false, false, true).isNotEmpty()) return@run
|
||||||
|
if (doScan(false, false, true, true).isNotEmpty()) return@run
|
||||||
|
if (doScan(true, true, true, true).isNotEmpty()) return@run
|
||||||
|
if (doScan(true, true, false, true).isNotEmpty()) return@run
|
||||||
|
if (doScan(true, false, true, true).isNotEmpty()) return@run
|
||||||
|
if (doScan(true, false, false, true).isNotEmpty()) return@run
|
||||||
|
if (doScan(false, true, true, true).isNotEmpty()) return@run
|
||||||
|
if (doScan(false, true, false, true).isNotEmpty()) return@run
|
||||||
|
|
||||||
|
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
|
||||||
|
|
||||||
|
generalSettings.isOffloadedFilteringDisabled.value = false
|
||||||
|
generalSettings.isOffloadedBatchingDisabled.value = false
|
||||||
|
generalSettings.useIndirectScanResultCallback.value = false
|
||||||
|
debugSettings.showUnfiltered.value = false
|
||||||
|
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
progress("We received at least some BLE data.\n")
|
||||||
|
|
||||||
|
run {
|
||||||
|
progress("Checking for supported headphones.")
|
||||||
|
|
||||||
|
if (doScan(false, false, false, false).any { it !is UnknownDevice }) return@run
|
||||||
|
if (doScan(false, false, true, false).any { it !is UnknownDevice }) return@run
|
||||||
|
if (doScan(true, true, true, false).any { it !is UnknownDevice }) return@run
|
||||||
|
if (doScan(true, true, false, false).any { it !is UnknownDevice }) return@run
|
||||||
|
if (doScan(true, false, true, false).any { it !is UnknownDevice }) return@run
|
||||||
|
if (doScan(true, false, false, false).any { it !is UnknownDevice }) return@run
|
||||||
|
if (doScan(false, true, true, false).any { it !is UnknownDevice }) return@run
|
||||||
|
if (doScan(false, true, false, false).any { it !is UnknownDevice }) return@run
|
||||||
|
|
||||||
|
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
|
||||||
|
|
||||||
|
generalSettings.isOffloadedFilteringDisabled.value = false
|
||||||
|
generalSettings.isOffloadedBatchingDisabled.value = false
|
||||||
|
generalSettings.useIndirectScanResultCallback.value = false
|
||||||
|
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
progress("Found some headphones that are supported by CAPod.\n")
|
||||||
|
|
||||||
|
run {
|
||||||
|
progress("Checking for your headphones with new BLE settings...")
|
||||||
|
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||||
|
podMonitor.mainDevice.filterNotNull().firstOrNull()
|
||||||
|
}
|
||||||
|
if (mainDevice != null) {
|
||||||
|
success("Found your headphones, new BLE settings worked :)!")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progress("Still no headphones detected that count as yours.\n")
|
||||||
|
|
||||||
|
run {
|
||||||
|
progress("Checking all closeby headphones.")
|
||||||
|
|
||||||
|
var otherDevices = withTimeoutOrNull(STEP_TIME) {
|
||||||
|
podMonitor.devices.take(10).toList().flatten()
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
if (otherDevices.isEmpty()) {
|
||||||
|
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
progress("Headphones found nearby, but not detected as yours. Resetting filters.\n")
|
||||||
|
|
||||||
|
generalSettings.mainDeviceModel.value = PodDevice.Model.UNKNOWN
|
||||||
|
generalSettings.mainDeviceAddress.value = null
|
||||||
|
generalSettings.minimumSignalQuality.value = 0.25f
|
||||||
|
|
||||||
|
otherDevices = withTimeoutOrNull(STEP_TIME) {
|
||||||
|
withTimeoutOrNull(STEP_TIME) {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
podMonitor.devices
|
||||||
|
.take(10)
|
||||||
|
.takeWhile { System.currentTimeMillis() - start < STEP_TIME - 1000 }
|
||||||
|
.toList()
|
||||||
|
.flatten()
|
||||||
|
.distinctBy { it.address }
|
||||||
|
} ?: emptyList()
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
progress("Setting headphone with strongest signal as yours.")
|
||||||
|
|
||||||
|
generalSettings.mainDeviceModel.value = otherDevices.maxBy { it.signalQuality }.model
|
||||||
|
|
||||||
|
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||||
|
podMonitor.mainDevice.filterNotNull().firstOrNull()
|
||||||
|
}
|
||||||
|
if (mainDevice != null) {
|
||||||
|
generalSettings.mainDeviceModel.value = mainDevice.model
|
||||||
|
generalSettings.scannerMode.value = ScannerMode.BALANCED
|
||||||
|
success("Success! Detected your headphones.")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class BleState {
|
||||||
|
class Intro : BleState()
|
||||||
|
|
||||||
|
data class Working(
|
||||||
|
val current: String,
|
||||||
|
val history: List<String> = emptyList(),
|
||||||
|
) : BleState() {
|
||||||
|
|
||||||
|
val allSteps: List<String>
|
||||||
|
get() = history + current
|
||||||
|
|
||||||
|
fun nextStep(message: String) = this.copy(
|
||||||
|
current = message,
|
||||||
|
history = history + current
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toSuccess(message: String) = Result.Success(
|
||||||
|
history = history + message
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toFailure(message: String, type: Result.Failure.Type) = Result.Failure(
|
||||||
|
failureType = type,
|
||||||
|
history = history + message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Result : BleState() {
|
||||||
|
|
||||||
|
abstract val history: List<String>
|
||||||
|
|
||||||
|
data class Success(
|
||||||
|
override val history: List<String>,
|
||||||
|
) : Result()
|
||||||
|
|
||||||
|
data class Failure(
|
||||||
|
val failureType: Type,
|
||||||
|
override val history: List<String>,
|
||||||
|
) : Result() {
|
||||||
|
enum class Type {
|
||||||
|
PHONE,
|
||||||
|
HEADPHONES,
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val STEP_TIME = 10 * 1000L // 6 scans per 30 seconds max
|
||||||
|
|
||||||
|
val TAG = logTag("TroubleShooter", "Fragment", "VM")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,12 +32,22 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="16dp"
|
android:layout_marginHorizontal="16dp"
|
||||||
android:layout_marginTop="4dp"
|
android:layout_marginTop="4dp"
|
||||||
android:layout_marginBottom="16dp"
|
|
||||||
android:text="@string/overview_nomaindevice_description"
|
android:text="@string/overview_nomaindevice_description"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toTopOf="@id/troubleshoot_action"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/permission_label" />
|
app:layout_constraintTop_toBottomOf="@id/permission_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/troubleshoot_action"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
android:text="@string/troubleshoot_action"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/permission_description" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
style="@style/Widget.MaterialComponents.Toolbar.Primary"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:title="@string/troubleshooter_title" />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/toolbar">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/ble_card"
|
||||||
|
style="@style/Widget.Material3.CardView.Filled"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/ble_intro_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible">
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
style="@style/TextAppearance.Material3.TitleMedium"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/troubleshooter_ble_intro_title" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
style="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/troubleshooter_ble_intro_body1" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/ble_intro_start_action"
|
||||||
|
style="@style/Widget.Material3.Button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="@string/troubleshooter_ble_intro_start_action" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/ble_process_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||||
|
tools:visibility="visible">
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
|
android:id="@+id/ble_process_progress"
|
||||||
|
style="@style/Widget.Material3.CircularProgressIndicator.Small"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:indeterminate="true"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/ble_process_secondary"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/ble_process_primary" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/ble_process_primary"
|
||||||
|
style="@style/TextAppearance.Material3.TitleMedium"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="@string/troubleshooter_ble_process_title"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/ble_process_secondary"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/ble_process_progress"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/ble_process_secondary"
|
||||||
|
style="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/troubleshooter_ble_process_subtile"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/ble_process_history"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="@id/ble_process_primary"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/ble_process_primary" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/ble_process_history"
|
||||||
|
style="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/ble_process_secondary"
|
||||||
|
tools:text="Step 1\nStep 2\n\Step 3" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/ble_result_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||||
|
tools:visibility="visible">
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/ble_result_title"
|
||||||
|
style="@style/TextAppearance.Material3.TitleMedium"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/troubleshooter_ble_result_success_title" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/ble_result_body"
|
||||||
|
style="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/troubleshooter_ble_result_success_body" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/ble_result_action"
|
||||||
|
style="@style/Widget.Material3.Button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="@string/troubleshooter_ble_result_failure_action" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/ble_result_history"
|
||||||
|
style="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
tools:text="Step 1\nStep 2\n\Step 3" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
<action
|
<action
|
||||||
android:id="@+id/action_overviewFragment_to_settingsFragment"
|
android:id="@+id/action_overviewFragment_to_settingsFragment"
|
||||||
app:destination="@id/settingsFragment" />
|
app:destination="@id/settingsFragment" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_overviewFragment_to_troubleShooterFragment"
|
||||||
|
app:destination="@id/troubleShooterFragment" />
|
||||||
</fragment>
|
</fragment>
|
||||||
|
|
||||||
<fragment
|
<fragment
|
||||||
@@ -20,5 +23,9 @@
|
|||||||
android:name="eu.darken.capod.main.ui.settings.SettingsFragment"
|
android:name="eu.darken.capod.main.ui.settings.SettingsFragment"
|
||||||
android:label="SettingsFragment"
|
android:label="SettingsFragment"
|
||||||
tools:layout="@layout/settings_fragment" />
|
tools:layout="@layout/settings_fragment" />
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/troubleShooterFragment"
|
||||||
|
android:name="eu.darken.capod.troubleshooter.ui.TroubleShooterFragment"
|
||||||
|
tools:layout="@layout/troubleshooter_fragment" />
|
||||||
|
|
||||||
</navigation>
|
</navigation>
|
||||||
@@ -102,4 +102,19 @@
|
|||||||
<string name="widget_description">A widget showing the last known device status.</string>
|
<string name="widget_description">A widget showing the last known device status.</string>
|
||||||
<string name="settings_compat_indirectcallback_title">Indirect data delivery</string>
|
<string name="settings_compat_indirectcallback_title">Indirect data delivery</string>
|
||||||
<string name="settings_compat_indirectcallback_summary">Use an alternative method to receive BLE data from the system (broadcast instead of callback).</string>
|
<string name="settings_compat_indirectcallback_summary">Use an alternative method to receive BLE data from the system (broadcast instead of callback).</string>
|
||||||
|
|
||||||
|
<string name="troubleshooter_title">Troubleshooter</string>
|
||||||
|
<string name="troubleshooter_ble_intro_body1">AirPods broadcast their status information using a BLE technology called \"advertisements\". Some devices don\'t implement this technology correctly. This process will try different compatibility settings to fix the issue on your device. Setup your headphones to play music and place them close to your phone while this process is running.</string>
|
||||||
|
<string name="troubleshooter_ble_intro_title">Bluetooth Low Energy Broadcasts</string>
|
||||||
|
<string name="troubleshooter_ble_intro_start_action">Start investigating</string>
|
||||||
|
<string name="troubleshooter_ble_process_title">Troubleshoot in progress</string>
|
||||||
|
<string name="troubleshooter_ble_process_subtile">Each step takes 5–10 seconds</string>
|
||||||
|
<string name="troubleshooter_ble_result_success_title">Success</string>
|
||||||
|
<string name="troubleshooter_ble_result_success_body">BLE advertisement broadcasts are being received by CAPod.</string>
|
||||||
|
<string name="troubleshooter_ble_result_failure_title">Unsuccessful</string>
|
||||||
|
<string name="troubleshooter_ble_result_failure_body">No combination of compatibility options helped.</string>
|
||||||
|
<string name="troubleshooter_ble_result_failure_phone_body">Your phone didn\'t receive any BLE data at all. You can retry this test in a crowded area to see if data sources (other than your headphones) can be received. No data being received points towards an issue with your phones operating system.</string>
|
||||||
|
<string name="troubleshooter_ble_result_failure_phone_headphones">Your phone received BLE data, but the data does not come from any supported device. Are your headphones powered on? Does CAPod support your headphone?</string>
|
||||||
|
<string name="troubleshooter_ble_result_failure_action">Discuss on Discord</string>
|
||||||
|
<string name="troubleshoot_action">Troubleshoot</string>
|
||||||
</resources>
|
</resources>
|
||||||
+1
-1
@@ -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}")
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
¯\_(ツ)_/¯
|
||||||
@@ -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.
|
||||||
|
|||||||
+2
-2
@@ -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=9
|
||||||
project.versioning.patch=2
|
project.versioning.patch=0
|
||||||
project.versioning.build=0
|
project.versioning.build=0
|
||||||
#############################
|
#############################
|
||||||
|
|||||||
Reference in New Issue
Block a user