Compare commits

..
Author SHA1 Message Date
darken da11961611 Release: 2.12.0-rc0 2023-10-28 18:36:11 +02:00
darken 48b9952fc0 Update translations 2023-10-28 18:21:50 +02:00
darken 99fa99eba5 Update billing client 2023-10-28 18:03:28 +02:00
darken 06e67e04ac Move permission 2023-10-28 17:58:09 +02:00
darken 4a2f7c20a2 Update unit test 2023-10-28 17:58:09 +02:00
darken 82e8769407 Bump dependencies and API targets. 2023-10-28 17:58:09 +02:00
darken ad38702df1 Widget: Center device label 2023-10-28 16:59:52 +02:00
darken 3f98c62a8f Extend test case for AirPods Pro 2 (USB-C)
Closes #164
2023-09-24 12:01:15 +02:00
26 changed files with 195 additions and 154 deletions
+1 -1
View File
@@ -1 +1 @@
2.11.1-rc0 21101000 2.12.0-rc0 21200000
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"> <resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string> <string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="general_grant_permission_action">Conceder permiso</string> <string name="general_grant_permission_action">Conceder permiso</string>
<string name="permission_bluetooth_connect_label">Conexión Bluetooth</string> <string name="permission_bluetooth_connect_label">Conexión Bluetooth</string>
<string name="permission_bluetooth_connect_description">Esta aplicacion requiere el permiso \"Conexión Bluetooth\" para interactuar con los dispositivos vinculados e iniciar las conexiones.</string> <string name="permission_bluetooth_connect_description">Esta aplicacion requiere el permiso \"Conexión Bluetooth\" para interactuar con los dispositivos vinculados e iniciar las conexiones.</string>
@@ -58,4 +58,6 @@
<string name="pods_case_unknown_state">Status tidak diketahui</string> <string name="pods_case_unknown_state">Status tidak diketahui</string>
<string name="last_seen_x">Terakhir terlihat: %s</string> <string name="last_seen_x">Terakhir terlihat: %s</string>
<string name="first_seen_x">Pertama kali terlihat: %s</string> <string name="first_seen_x">Pertama kali terlihat: %s</string>
<string name="permission_post_notifications_label">Tampilkan notifikasi</string>
<string name="permission_post_notifications_description">"Izinkan CAPod untuk menampilkan notifikasi tentang AirPods anda, contoh. status saat terhubung."</string>
</resources> </resources>
@@ -58,4 +58,6 @@
<string name="pods_case_unknown_state">Estado desconhecido</string> <string name="pods_case_unknown_state">Estado desconhecido</string>
<string name="last_seen_x">Visto pela última vez: %s</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="first_seen_x">Visto pela primeira vez: %s</string>
<string name="permission_post_notifications_label">Mostrar notificações</string>
<string name="permission_post_notifications_description">"Permita que o CAPod mostre notificações sobre seus AirPods, por exemplo. seu status atual enquanto conectado."</string>
</resources> </resources>
@@ -3,80 +3,69 @@ package eu.darken.capod.common.flow
import eu.darken.capod.common.collections.mutate import eu.darken.capod.common.collections.mutate
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.instanceOf
import io.kotest.matchers.types.shouldBeInstanceOf import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.* import kotlinx.coroutines.test.advanceUntilIdle
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import testhelpers.BaseTest import testhelpers.BaseTest
import testhelpers.coroutine.runTest2 import testhelpers.coroutine.runTest2
import testhelpers.flow.test import testhelpers.flow.test
import java.io.IOException import java.io.IOException
import java.lang.Thread.sleep
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.coroutines.EmptyCoroutineContext
class DynamicStateFlowTest : BaseTest() { class DynamicStateFlowTest : BaseTest() {
// Without an init value, there isn't a way to keep using the flow // Without an init value, there isn't a way to keep using the flow
@Test @Test
fun `exceptions on initialization are rethrown`() { fun `exceptions on initialization are rethrown`() = runTest2(expectedError = IOException::class) {
val testScope = val testScope = this
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val hotData = eu.darken.capod.common.flow.DynamicStateFlow<String>( val hotData = DynamicStateFlow<String>(
loggingTag = "tag", loggingTag = "tag",
parentScope = testScope, parentScope = testScope,
coroutineContext = Dispatchers.Unconfined, coroutineContext = Dispatchers.Unconfined,
startValueProvider = { throw IOException() } startValueProvider = { throw IOException() }
) )
runBlocking {
withTimeoutOrNull(500) {
// This blocking scope gets the init exception as the first caller
hotData.flow.firstOrNull()
} shouldBe null
}
testScope.advanceUntilIdle() // This blocking scope gets the init exception as the first caller
hotData.flow.firstOrNull()
testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class)
} }
@Test @Test
fun `subscription doesn't end when no subscriber is collecting, mode Lazily`() { fun `subscription doesn't end when no subscriber is collecting, mode Lazily`() = runTest2(autoCancel = true) {
val testScope = val testScope = this
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val valueProvider = mockk<suspend CoroutineScope.() -> String>() val valueProvider = mockk<suspend CoroutineScope.() -> String>()
coEvery { valueProvider.invoke(any()) } returns "Test" coEvery { valueProvider.invoke(any()) } returns "Test"
val hotData = eu.darken.capod.common.flow.DynamicStateFlow( val hotData = DynamicStateFlow(
loggingTag = "tag", loggingTag = "tag",
parentScope = testScope, parentScope = testScope,
coroutineContext = Dispatchers.Unconfined, coroutineContext = Dispatchers.Unconfined,
startValueProvider = valueProvider, startValueProvider = valueProvider,
) )
testScope.apply { hotData.flow.first() shouldBe "Test"
runTest2(autoCancel = true) { hotData.flow.first() shouldBe "Test"
hotData.flow.first() shouldBe "Test"
hotData.flow.first() shouldBe "Test" coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
} }
@Test @Test
fun `value updates`() { fun `value updates`() = runTest2(autoCancel = true) {
val testScope = val testScope = this
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val valueProvider = mockk<suspend CoroutineScope.() -> Long>() val valueProvider = mockk<suspend CoroutineScope.() -> Long>()
coEvery { valueProvider.invoke(any()) } returns 1 coEvery { valueProvider.invoke(any()) } returns 1
val hotData = eu.darken.capod.common.flow.DynamicStateFlow( val hotData = DynamicStateFlow(
loggingTag = "tag", loggingTag = "tag",
parentScope = testScope, parentScope = testScope,
startValueProvider = valueProvider, startValueProvider = valueProvider,
@@ -88,7 +77,6 @@ class DynamicStateFlowTest : BaseTest() {
(1..16).forEach { _ -> (1..16).forEach { _ ->
thread { thread {
(1..200).forEach { _ -> (1..200).forEach { _ ->
sleep(10)
hotData.updateAsync( hotData.updateAsync(
onUpdate = { this + 1L }, onUpdate = { this + 1L },
onError = { throw it } onError = { throw it }
@@ -97,12 +85,16 @@ class DynamicStateFlowTest : BaseTest() {
} }
} }
runBlocking { advanceUntilIdle()
testCollector.await { list, _ -> list.size == 3201 }
testCollector.latestValues shouldBe (1..3201).toList() testCollector.await { list, _ -> list.size == 3201 }
} testCollector.latestValues shouldBe (1..3201).toList()
advanceUntilIdle()
coVerify(exactly = 1) { valueProvider.invoke(any()) } coVerify(exactly = 1) { valueProvider.invoke(any()) }
testCollector.cancelAndJoin()
} }
data class TestData( data class TestData(
@@ -110,14 +102,13 @@ class DynamicStateFlowTest : BaseTest() {
) )
@Test @Test
fun `check multi threading value updates with more complex data`() { fun `check multi threading value updates with more complex data`() = runTest2(autoCancel = true) {
val testScope = val testScope = this
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val valueProvider =
mockk<suspend CoroutineScope.() -> Map<String, eu.darken.capod.common.flow.DynamicStateFlowTest.TestData>>()
coEvery { valueProvider.invoke(any()) } returns mapOf("data" to eu.darken.capod.common.flow.DynamicStateFlowTest.TestData())
val hotData = eu.darken.capod.common.flow.DynamicStateFlow( val valueProvider = mockk<suspend CoroutineScope.() -> Map<String, TestData>>()
coEvery { valueProvider.invoke(any()) } returns mapOf("data" to TestData())
val hotData = DynamicStateFlow(
loggingTag = "tag", loggingTag = "tag",
parentScope = testScope, parentScope = testScope,
startValueProvider = valueProvider, startValueProvider = valueProvider,
@@ -140,20 +131,23 @@ class DynamicStateFlowTest : BaseTest() {
} }
} }
runBlocking { advanceUntilIdle()
testCollector.await { list, _ -> list.size == 4001 }
testCollector.latestValues.map { it.values.single().number } shouldBe (1L..4001L).toList() testCollector.await { list, _ -> list.size == 4001 }
} testCollector.latestValues.map { it.values.single().number } shouldBe (1L..4001L).toList()
advanceUntilIdle()
coVerify(exactly = 1) { valueProvider.invoke(any()) } coVerify(exactly = 1) { valueProvider.invoke(any()) }
testCollector.cancelAndJoin()
} }
@Test @Test
fun `only emit new values if they actually changed updates`() { fun `only emit new values if they actually changed updates`() = runTest2(autoCancel = true) {
val testScope = val testScope = this
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val hotData = eu.darken.capod.common.flow.DynamicStateFlow( val hotData = DynamicStateFlow(
loggingTag = "tag", loggingTag = "tag",
parentScope = testScope, parentScope = testScope,
startValueProvider = { "1" }, startValueProvider = { "1" },
@@ -167,26 +161,28 @@ class DynamicStateFlowTest : BaseTest() {
hotData.updateAsync { "2" } hotData.updateAsync { "2" }
hotData.updateAsync { "1" } hotData.updateAsync { "1" }
runBlocking { advanceUntilIdle()
testCollector.await { list, _ -> list.size == 3 }
testCollector.latestValues shouldBe listOf("1", "2", "1") testCollector.await { list, _ -> list.size == 3 }
} testCollector.latestValues shouldBe listOf("1", "2", "1")
} }
@Test @Test
fun `multiple subscribers share the flow`() = runTest2(autoCancel = true) { fun `multiple subscribers share the flow`() = runTest2(autoCancel = true) {
val testScope = this
val valueProvider = mockk<suspend CoroutineScope.() -> String>() val valueProvider = mockk<suspend CoroutineScope.() -> String>()
coEvery { valueProvider.invoke(any()) } returns "Test" coEvery { valueProvider.invoke(any()) } returns "Test"
val hotData = eu.darken.capod.common.flow.DynamicStateFlow( val hotData = DynamicStateFlow(
loggingTag = "tag", loggingTag = "tag",
parentScope = this, parentScope = testScope,
startValueProvider = valueProvider, startValueProvider = valueProvider,
) )
val sub1 = hotData.flow.test(tag = "sub1", scope = this) val sub1 = hotData.flow.test(tag = "sub1", scope = testScope)
val sub2 = hotData.flow.test(tag = "sub2", scope = this) val sub2 = hotData.flow.test(tag = "sub2", scope = testScope)
val sub3 = hotData.flow.test(tag = "sub3", scope = this) val sub3 = hotData.flow.test(tag = "sub3", scope = testScope)
hotData.updateAsync { "A" } hotData.updateAsync { "A" }
hotData.updateAsync { "B" } hotData.updateAsync { "B" }
@@ -207,17 +203,19 @@ class DynamicStateFlowTest : BaseTest() {
@Test @Test
fun `value is persisted between unsubscribes`() = runTest2(autoCancel = true) { fun `value is persisted between unsubscribes`() = runTest2(autoCancel = true) {
val testScope = this
val valueProvider = mockk<suspend CoroutineScope.() -> Long>() val valueProvider = mockk<suspend CoroutineScope.() -> Long>()
coEvery { valueProvider.invoke(any()) } returns 1 coEvery { valueProvider.invoke(any()) } returns 1
val hotData = eu.darken.capod.common.flow.DynamicStateFlow( val hotData = DynamicStateFlow(
loggingTag = "tag", loggingTag = "tag",
parentScope = this, parentScope = testScope,
coroutineContext = this.coroutineContext, coroutineContext = this.coroutineContext,
startValueProvider = valueProvider, startValueProvider = valueProvider,
) )
val testCollector1 = hotData.flow.test(tag = "collector1", scope = this) val testCollector1 = hotData.flow.test(tag = "collector1", scope = testScope)
testCollector1.silent = false testCollector1.silent = false
(1..10).forEach { _ -> (1..10).forEach { _ ->
@@ -233,7 +231,7 @@ class DynamicStateFlowTest : BaseTest() {
testCollector1.cancelAndJoin() testCollector1.cancelAndJoin()
val testCollector2 = hotData.flow.test(tag = "collector2", scope = this) val testCollector2 = hotData.flow.test(tag = "collector2", scope = testScope)
testCollector2.silent = false testCollector2.silent = false
advanceUntilIdle() advanceUntilIdle()
@@ -246,10 +244,10 @@ class DynamicStateFlowTest : BaseTest() {
} }
@Test @Test
fun `blocking update is actually blocking`() = runBlocking { fun `blocking update is actually blocking`() = runTest2(autoCancel = true) {
val testScope = val testScope = this
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val hotData = eu.darken.capod.common.flow.DynamicStateFlow( val hotData = DynamicStateFlow(
loggingTag = "tag", loggingTag = "tag",
parentScope = testScope, parentScope = testScope,
coroutineContext = testScope.coroutineContext, coroutineContext = testScope.coroutineContext,
@@ -270,6 +268,8 @@ class DynamicStateFlowTest : BaseTest() {
hotData.updateBlocking { this - 3 } shouldBe 0 hotData.updateBlocking { this - 3 } shouldBe 0
advanceUntilIdle()
testCollector.await { _, i -> i == 3 } testCollector.await { _, i -> i == 3 }
testCollector.latestValues shouldBe listOf(2, 3, 0) testCollector.latestValues shouldBe listOf(2, 3, 0)
@@ -277,10 +277,10 @@ class DynamicStateFlowTest : BaseTest() {
} }
@Test @Test
fun `blocking update rethrows error`() = runBlocking { fun `blocking update rethrows error`() = runTest2(autoCancel = true) {
val testScope = val testScope = this
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val hotData = eu.darken.capod.common.flow.DynamicStateFlow( val hotData = DynamicStateFlow(
loggingTag = "tag", loggingTag = "tag",
parentScope = testScope, parentScope = testScope,
coroutineContext = testScope.coroutineContext, coroutineContext = testScope.coroutineContext,
@@ -300,16 +300,17 @@ class DynamicStateFlowTest : BaseTest() {
hotData.flow.first() shouldBe 2 hotData.flow.first() shouldBe 2
hotData.updateBlocking { 3 } shouldBe 3 hotData.updateBlocking { 3 } shouldBe 3
hotData.flow.first() shouldBe 3
testScope.uncaughtExceptions.singleOrNull() shouldBe null advanceUntilIdle()
hotData.flow.first() shouldBe 3
testCollector.cancelAndJoin() testCollector.cancelAndJoin()
} }
@Test @Test
fun `async updates error handler`() = runTest2(expectedError = IOException::class) { fun `async updates error handler`() = runTest2(expectedError = IOException::class) {
val hotData = eu.darken.capod.common.flow.DynamicStateFlow( val hotData = DynamicStateFlow(
loggingTag = "tag", loggingTag = "tag",
parentScope = this, parentScope = this,
startValueProvider = { 1 }, startValueProvider = { 1 },
@@ -321,14 +322,15 @@ class DynamicStateFlowTest : BaseTest() {
hotData.updateAsync { throw IOException("Surprise") } hotData.updateAsync { throw IOException("Surprise") }
advanceUntilIdle() advanceUntilIdle()
testCollector.cancelAndJoin()
} }
@Test @Test
fun `async updates rethrow errors on HotDataFlow scope if no error handler is set`() = runBlocking { fun `async updates rethrow errors on HotDataFlow scope if no error handler is set`() = runTest2(autoCancel = true) {
val testScope = val testScope = this
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val hotData = eu.darken.capod.common.flow.DynamicStateFlow( val hotData = DynamicStateFlow(
loggingTag = "tag", loggingTag = "tag",
parentScope = testScope, parentScope = testScope,
startValueProvider = { 1 }, startValueProvider = { 1 },
@@ -346,31 +348,29 @@ class DynamicStateFlowTest : BaseTest() {
testScope.advanceUntilIdle() testScope.advanceUntilIdle()
thrownError!!.shouldBeInstanceOf<IOException>() thrownError!!.shouldBeInstanceOf<IOException>()
testScope.uncaughtExceptions.singleOrNull() shouldBe null
testCollector.cancelAndJoin() testCollector.cancelAndJoin()
} }
@Test @Test
fun `clean up function is called when parent scope is cancelled`() = runTest { fun `clean up function is called when parent scope is cancelled`() {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
var onReleaseValue: String? = null var onReleaseValue: String? = null
val hotData = eu.darken.capod.common.flow.DynamicStateFlow( runTest2(autoCancel = true) {
loggingTag = "tag", val testScope = this
parentScope = testScope,
coroutineContext = Dispatchers.Unconfined,
startValueProvider = { "Test" },
onRelease = {
onReleaseValue = it
}
)
hotData.flow.first() shouldBe "Test" val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = Dispatchers.Unconfined,
startValueProvider = { "Test" },
onRelease = {
onReleaseValue = it
}
)
testScope.cancel() hotData.flow.first() shouldBe "Test"
}
onReleaseValue shouldBe "Test" onReleaseValue shouldBe "Test"
} }
@@ -45,4 +45,28 @@ class AirPodsPro2UsbcTest : BaseAirPodsTest() {
model shouldBe PodDevice.Model.AIRPODS_PRO2_USBC model shouldBe PodDevice.Model.AIRPODS_PRO2_USBC
} }
} }
@Test
fun `AirPods Pro 2 with USB-C - via #164 - in case`() = runTest {
create<AirPodsPro2Usbc>("07 19 01 24 20 53 AA 98 32 00 05 49 0A B8 BF 8E 29 D8 70 12 0D A7 0C CE 77 56 00") {
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe false
batteryLeftPodPercent shouldBe 1f
batteryRightPodPercent shouldBe 1f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.8f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO2_USBC
}
}
} }
@@ -1,24 +0,0 @@
package testhelpers.coroutine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext
@ExperimentalCoroutinesApi
class CoroutinesTestExtension(
private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : BeforeEachCallback, AfterEachCallback,
TestCoroutineScope by createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + dispatcher) {
override fun beforeEach(context: ExtensionContext?) {
Dispatchers.setMain(dispatcher)
}
override fun afterEach(context: ExtensionContext?) {
cleanupTestCoroutines()
Dispatchers.resetMain()
}
}
+3 -3
View File
@@ -125,7 +125,7 @@ android {
dependencies { dependencies {
implementation(project(":app-common")) implementation(project(":app-common"))
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
addBaseKotlin() addBaseKotlin()
@@ -145,6 +145,6 @@ dependencies {
addTesting() addTesting()
"gplayImplementation"("com.android.billingclient:billing:5.1.0") "gplayImplementation"("com.android.billingclient:billing:6.0.1")
"gplayImplementation"("com.android.billingclient:billing-ktx:5.1.0") "gplayImplementation"("com.android.billingclient:billing-ktx:6.0.1")
} }
@@ -1,7 +1,13 @@
package eu.darken.capod.common.upgrade.core.client 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.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.QueryProductDetailsParams
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO 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
@@ -20,11 +26,15 @@ data class BillingClientConnection(
) { ) {
private val purchasesLocal = MutableStateFlow<Collection<Purchase>>(emptySet()) private val purchasesLocal = MutableStateFlow<Collection<Purchase>>(emptySet())
val purchases: Flow<Collection<Purchase>> = combine(purchasesGlobal, purchasesLocal) { global, local -> val purchases: Flow<Collection<Purchase>> = combine(purchasesGlobal, purchasesLocal) { global, local ->
val combined = mutableMapOf<String, Purchase>() val combined = mutableSetOf<Purchase>()
global.plus(local).toSet().sortedByDescending { it.purchaseTime }.forEach { purchase ->
combined[purchase.orderId] = purchase combined.addAll(local)
}
combined.values global
.let { purchases -> purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED } }
.let { combined.addAll(it) }
combined.sortedByDescending { it.purchaseTime }
} }
.setupCommonEventHandlers(TAG) { "purchases" } .setupCommonEventHandlers(TAG) { "purchases" }
+2
View File
@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-feature <uses-feature
android:name="android.hardware.bluetooth_le" android:name="android.hardware.bluetooth_le"
android:required="true" /> android:required="true" />
@@ -53,7 +53,7 @@ abstract class PreferenceFragment2
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
} }
@@ -106,6 +106,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:textSize="12sp" android:textSize="12sp"
android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
tools:text="AirPods Max" /> tools:text="AirPods Max" />
@@ -9,6 +9,7 @@
style="@style/PodWidget.TextPrimary" style="@style/PodWidget.TextPrimary"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:textStyle="bold" android:textStyle="bold"
android:gravity="center"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
tools:text="AirPods Max" /> tools:text="AirPods Max" />
+5
View File
@@ -101,4 +101,9 @@
<string name="troubleshooter_ble_result_failure_phone_headphones">Ihr Telefon hat BLE-Daten empfangen, aber die Daten stammen von keinem unterstützten Gerät. Sind Ihre Kopfhörer eingeschaltet? Unterstützt CAPod Ihren Kopfhörer?</string> <string name="troubleshooter_ble_result_failure_phone_headphones">Ihr Telefon hat BLE-Daten empfangen, aber die Daten stammen von keinem unterstützten Gerät. Sind Ihre Kopfhörer eingeschaltet? Unterstützt CAPod Ihren Kopfhörer?</string>
<string name="troubleshooter_ble_result_failure_action">Versuchen Sie es erneut</string> <string name="troubleshooter_ble_result_failure_action">Versuchen Sie es erneut</string>
<string name="troubleshoot_action">Fehlerbehebung</string> <string name="troubleshoot_action">Fehlerbehebung</string>
<string name="onboarding_body1">Diese App fügt Support für AirPod-Spezifische funkionen zu Android hinzu.</string>
<string name="onboarding_body2">Nicht alle Android-Geräte unterstützen die zusätzlichen AirPod-Funktionen vollständig. Ihr Betriebssystem erfordert eine korrekt funktionierende Bluetooth-Low-Energy-Implementierung.</string>
<string name="onboarding_body3">CAPod hat keine Werbung und sammelt Ihre Daten nicht.</string>
<string name="onboarding_body4">Sie können auf CAPod Pro upgraden, um zusätzliche Funktionen zu erhalten und die Entwicklung zu unterstützen.</string>
<string name="general_continue_action">Fortfahren</string>
</resources> </resources>
+9
View File
@@ -45,7 +45,10 @@
<string name="settings_maindevice_model_description">Model perangkat utama anda. Ini membantu aplikasi mengenali perangkat anda saat tidak terhubung ke ponsel anda.</string> <string name="settings_maindevice_model_description">Model perangkat utama anda. Ini membantu aplikasi mengenali perangkat anda saat tidak terhubung ke ponsel anda.</string>
<string name="settings_onepod_mode_label">Mode satu pod</string> <string name="settings_onepod_mode_label">Mode satu pod</string>
<string name="settings_onepod_mode_description">Menggunakan kedua pod tidak diperlukan, memakai satu pod sudah cukup untuk memicu reaksi.</string> <string name="settings_onepod_mode_description">Menggunakan kedua pod tidak diperlukan, memakai satu pod sudah cukup untuk memicu reaksi.</string>
<string name="settings_popup_caseopen_label">Tampilkan popup kasing</string>
<string name="settings_popup_caseopen_description">Tampilkan popup saat kasing perangkat dibuka (eksperimental).</string> <string name="settings_popup_caseopen_description">Tampilkan popup saat kasing perangkat dibuka (eksperimental).</string>
<string name="settings_popup_connected_label">Tampilkan popup saat terkoneksi</string>
<string name="settings_popup_connected_description">Tampilkan popup ketika perangkat terhubung pertama kalinya.</string>
<string name="notification_channel_device_status_label">Status perangkat</string> <string name="notification_channel_device_status_label">Status perangkat</string>
<string name="debug_debuglog_size_label">Ukuran</string> <string name="debug_debuglog_size_label">Ukuran</string>
<string name="debug_debuglog_size_compressed_label">Ukuran terkompresi</string> <string name="debug_debuglog_size_compressed_label">Ukuran terkompresi</string>
@@ -94,7 +97,13 @@
<string name="troubleshooter_ble_result_success_body">Siaran iklan BLE diterima oleh CAPod.</string> <string name="troubleshooter_ble_result_success_body">Siaran iklan BLE diterima oleh CAPod.</string>
<string name="troubleshooter_ble_result_failure_title">Gagal</string> <string name="troubleshooter_ble_result_failure_title">Gagal</string>
<string name="troubleshooter_ble_result_failure_body">Pemecahan masalah gagal. Tidak ada kombinasi opsi kompatibilitas yang membantu.</string> <string name="troubleshooter_ble_result_failure_body">Pemecahan masalah gagal. Tidak ada kombinasi opsi kompatibilitas yang membantu.</string>
<string name="troubleshooter_ble_result_failure_phone_body">Ponsel Anda tidak menerima data BLE sama sekali. Anda dapat mencoba lagi pengujian ini di area yang ramai untuk melihat apakah sumber data (selain headphone Anda) dapat diterima. Tidak ada data yang diterima menunjukkan masalah dengan sistem operasi ponsel Anda.</string>
<string name="troubleshooter_ble_result_failure_phone_headphones">Telepon anda menerima data BLE, tetapi data tersebut tidak berasal dari perangkat yang didukung. Apakah headphone Anda dihidupkan? Apakah CAPod mendukung headphone Anda?</string> <string name="troubleshooter_ble_result_failure_phone_headphones">Telepon anda menerima data BLE, tetapi data tersebut tidak berasal dari perangkat yang didukung. Apakah headphone Anda dihidupkan? Apakah CAPod mendukung headphone Anda?</string>
<string name="troubleshooter_ble_result_failure_action">Coba lagi</string> <string name="troubleshooter_ble_result_failure_action">Coba lagi</string>
<string name="troubleshoot_action">Memecahkan masalah</string> <string name="troubleshoot_action">Memecahkan masalah</string>
<string name="onboarding_body1">Aplikasi ini menambahkan bantuan untuk fitur spesifik AirPod untuk Android.</string>
<string name="onboarding_body2">Tidak semua perangkat Android mendukung fitur AirPod ekstra. Sistem Os anda memerlukan implementasi Bluetooth-Low-Energy yang bekerja.</string>
<string name="onboarding_body3">CAPod tidak menampilkan iklan dan tidak mengambil data anda.</string>
<string name="onboarding_body4">Anda bisa membeli CAPod Pro agar bisa menggunakan fitur ekstra dan mendukung pengembangan aplikasi.</string>
<string name="general_continue_action">Lanjutkan</string>
</resources> </resources>
@@ -45,7 +45,10 @@
<string name="settings_maindevice_model_description">O modelo do seu dispositivo principal. Isso ajuda o aplicativo a reconhecer seu dispositivo quando ele não está conectado ao seu telefone.</string> <string name="settings_maindevice_model_description">O modelo do seu dispositivo principal. Isso ajuda o aplicativo a reconhecer seu dispositivo quando ele não está conectado ao seu telefone.</string>
<string name="settings_onepod_mode_label">Modo de um airpod</string> <string name="settings_onepod_mode_label">Modo de um airpod</string>
<string name="settings_onepod_mode_description">Não é necessário usar os dois airpods, usar um único airpod é suficiente para destravar reações.</string> <string name="settings_onepod_mode_description">Não é necessário usar os dois airpods, usar um único airpod é suficiente para destravar reações.</string>
<string name="settings_popup_caseopen_label">Mostrar pop-up da case</string>
<string name="settings_popup_caseopen_description">Mostrar um pop-up quando o estojo do dispositivo for aberto (experimental).</string> <string name="settings_popup_caseopen_description">Mostrar um pop-up quando o estojo do dispositivo for aberto (experimental).</string>
<string name="settings_popup_connected_label">Mostrar pop-up de conexão</string>
<string name="settings_popup_connected_description">Mostrar um pop-up quando o dispositivo se conectar pela primeira vez.</string>
<string name="notification_channel_device_status_label">Status do dispositivo</string> <string name="notification_channel_device_status_label">Status do dispositivo</string>
<string name="debug_debuglog_size_label">Tamanho</string> <string name="debug_debuglog_size_label">Tamanho</string>
<string name="debug_debuglog_size_compressed_label">Tamanho compactado</string> <string name="debug_debuglog_size_compressed_label">Tamanho compactado</string>
@@ -94,7 +97,13 @@
<string name="troubleshooter_ble_result_success_body">As transmissões de anúncios BLE estão sendo recebidas pelo CAPod.</string> <string name="troubleshooter_ble_result_success_body">As transmissões de anúncios BLE estão sendo recebidas pelo CAPod.</string>
<string name="troubleshooter_ble_result_failure_title">Sem sucesso</string> <string name="troubleshooter_ble_result_failure_title">Sem sucesso</string>
<string name="troubleshooter_ble_result_failure_body">A solução de problemas falhou. Nenhuma combinação de opções de compatibilidade ajudou.</string> <string name="troubleshooter_ble_result_failure_body">A solução de problemas falhou. Nenhuma combinação de opções de compatibilidade ajudou.</string>
<string name="troubleshooter_ble_result_failure_phone_body">Seu telefone não recebeu nenhum dado BLE. Você pode tentar novamente este teste em uma área lotada para ver se fontes de dados (além dos fones de ouvido) podem ser recebidas. Nenhum dado recebido indica um problema no sistema operacional do seu telefone.</string>
<string name="troubleshooter_ble_result_failure_phone_headphones">Seu telefone recebeu dados BLE, mas os dados não vêm de nenhum dispositivo compatível. Seus fones de ouvido estão ligados? O CAPod suporta seu fone de ouvido?</string> <string name="troubleshooter_ble_result_failure_phone_headphones">Seu telefone recebeu dados BLE, mas os dados não vêm de nenhum dispositivo compatível. Seus fones de ouvido estão ligados? O CAPod suporta seu fone de ouvido?</string>
<string name="troubleshooter_ble_result_failure_action">Tentar novamente</string> <string name="troubleshooter_ble_result_failure_action">Tentar novamente</string>
<string name="troubleshoot_action">Solucionar problemas</string> <string name="troubleshoot_action">Solucionar problemas</string>
<string name="onboarding_body1">Este aplicativo adiciona suporte para recursos específicos do AirPod ao Android.</string>
<string name="onboarding_body2">Nem todos os dispositivos Android oferecem suporte total aos recursos extras do AirPod. Seu sistema operacional requer uma implementação de Bluetooth-Low-Energy funcionando corretamente.</string>
<string name="onboarding_body3">CAPod não possui anúncios e não coleta seus dados.</string>
<string name="onboarding_body4">Você pode atualizar para o CAPod Pro para obter recursos extras e apoiar o desenvolvimento.</string>
<string name="general_continue_action">Continuar</string>
</resources> </resources>
+1 -1
View File
@@ -4,7 +4,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath("com.android.tools.build:gradle:8.0.2") classpath("com.android.tools.build:gradle:8.1.2")
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}")
+1 -1
View File
@@ -8,7 +8,7 @@ repositories {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("com.android.tools.build:gradle:8.0.2") implementation("com.android.tools.build:gradle:8.1.2")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0") implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0")
implementation("com.squareup:javapoet:1.13.0") implementation("com.squareup:javapoet:1.13.0")
} }
+5 -5
View File
@@ -84,8 +84,8 @@ fun DependencyHandlerScope.addBaseWorkManager() {
} }
fun DependencyHandlerScope.addBaseAndroid() { fun DependencyHandlerScope.addBaseAndroid() {
implementation("androidx.core:core-ktx:1.10.0-rc01") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.annotation:annotation:1.4.0") implementation("androidx.annotation:annotation:1.7.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")
} }
@@ -95,12 +95,12 @@ fun DependencyHandlerScope.addBaseAndroidUi() {
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")
implementation("androidx.activity:activity-ktx:1.6.0-rc01") implementation("androidx.activity:activity-ktx:1.8.0")
implementation("androidx.fragment:fragment-ktx:1.5.0") implementation("androidx.fragment:fragment-ktx:1.6.1")
implementation("com.google.android.material:material:1.5.0-rc01") implementation("com.google.android.material:material:1.5.0-rc01")
val lifecycleVers = "2.5.0" val lifecycleVers = "2.6.2"
implementation("androidx.lifecycle:lifecycle-extensions:2.2.0") implementation("androidx.lifecycle:lifecycle-extensions:2.2.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVers") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVers")
implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVers") implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVers")
+2 -2
View File
@@ -14,8 +14,8 @@ object ProjectConfig {
const val packageName = "eu.darken.capod" const val packageName = "eu.darken.capod"
const val minSdk = 26 const val minSdk = 26
const val compileSdk = 33 const val compileSdk = 34
const val targetSdk = 33 const val targetSdk = 34
object Version { object Version {
val versionProperties = Properties().apply { val versionProperties = Properties().apply {
+4 -8
View File
@@ -1,11 +1,7 @@
object Versions { object Versions {
object Gradle {
const val buildTools = "7.1.2"
}
object Kotlin { object Kotlin {
const val core = "1.8.0" const val core = "1.9.10"
const val coroutines = "1.6.2" const val coroutines = "1.7.3"
} }
object Dokka { object Dokka {
@@ -13,7 +9,7 @@ object Versions {
} }
object Dagger { object Dagger {
const val core = "2.45" const val core = "2.48.1"
} }
object Moshi { object Moshi {
@@ -24,7 +20,7 @@ object Versions {
const val core = "" const val core = ""
object Navigation { object Navigation {
const val core = "2.5.0" const val core = "2.7.3"
} }
object Testing { object Testing {
@@ -0,0 +1 @@
Support for AirPods Pro 2 with USB-C
@@ -0,0 +1,2 @@
Bugfixes, performance improvements and maybe new features.
¯\_(ツ)_/¯
@@ -1,4 +1,4 @@
O CAPod é um aplicativo complementar para AirPods. CAPod é uma aplicação complementar aos AirPods.
Recursos: Recursos:
@@ -1,4 +1,4 @@
CAPod - програма-компаньйон для AirPods на Android. CAPod is a companion app for AirPods.
Функції: Функції:
+2 -2
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=11 project.versioning.minor=12
project.versioning.patch=1 project.versioning.patch=0
project.versioning.build=0 project.versioning.build=0
############################# #############################