From 07842fa8dff924d6a681154e78ae31929e0d13d6 Mon Sep 17 00:00:00 2001 From: darken Date: Sun, 16 Jan 2022 22:37:03 +0100 Subject: [PATCH] Add Gplay and FOSS upgrade scaffolding --- app/build.gradle | 40 +++-- .../capod/common/upgrade/UpgradeModule.kt | 17 +++ .../capod/common/upgrade/core/FossCache.kt | 24 +++ .../capod/common/upgrade/core/FossUpgrade.kt | 18 +++ .../common/upgrade/core/UpgradeControlFoss.kt | 71 +++++++++ app/src/foss/res/values/strings.xml | 6 + .../capod/common/upgrade/UpgradeModule.kt | 17 +++ .../capod/common/upgrade/core/BillingCache.kt | 20 +++ .../common/upgrade/core/BillingExtensions.kt | 13 ++ .../capod/common/upgrade/core/CapodSku.kt | 9 ++ .../common/upgrade/core/UpgradeRepoGplay.kt | 124 ++++++++++++++++ .../core/client/BillingClientConnection.kt | 109 ++++++++++++++ .../client/BillingClientConnectionProvider.kt | 119 +++++++++++++++ .../core/client/BillingClientException.kt | 11 ++ .../core/client/BillingClientExtensions.kt | 7 + .../GplayServiceUnavailableException.kt | 19 +++ .../common/upgrade/core/data/AvailableSku.kt | 5 + .../common/upgrade/core/data/BillingData.kt | 11 ++ .../upgrade/core/data/BillingDataRepo.kt | 139 ++++++++++++++++++ .../common/upgrade/core/data/PurchasedSku.kt | 8 + .../capod/common/upgrade/core/data/Sku.kt | 12 ++ app/src/gplay/res/values/strings.xml | 4 + app/src/main/AndroidManifest.xml | 19 ++- .../eu/darken/capod/common/BuildConfigWrap.kt | 8 +- .../eu/darken/capod/common/ColorExtensions.kt | 13 ++ .../common/bluetooth/BluetoothManager2.kt | 4 +- .../java/eu/darken/capod/common/debug/Bugs.kt | 12 +- .../serialization/JavaInstantAdapter.kt | 13 ++ .../serialization/SerializationModule.kt | 4 +- .../capod/common/upgrade/UpgradeRepo.kt | 24 +++ .../main/ui/overview/OverviewFragment.kt | 53 +++++++ .../main/ui/overview/OverviewFragmentVM.kt | 13 ++ .../monitor/core/worker/MonitorWorker.kt | 3 +- .../capod/pods/core/apple/AppleFactory.kt | 7 +- .../res/drawable/ic_baseline_heart_24.xml | 10 ++ .../res/drawable/ic_baseline_stars_24.xml | 10 ++ app/src/main/res/menu/main.xml | 17 ++- app/src/main/res/values/strings.xml | 12 +- 38 files changed, 989 insertions(+), 36 deletions(-) create mode 100644 app/src/foss/java/eu/darken/capod/common/upgrade/UpgradeModule.kt create mode 100644 app/src/foss/java/eu/darken/capod/common/upgrade/core/FossCache.kt create mode 100644 app/src/foss/java/eu/darken/capod/common/upgrade/core/FossUpgrade.kt create mode 100644 app/src/foss/java/eu/darken/capod/common/upgrade/core/UpgradeControlFoss.kt create mode 100644 app/src/foss/res/values/strings.xml create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/UpgradeModule.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingExtensions.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/CapodSku.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientException.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientExtensions.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/GplayServiceUnavailableException.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/AvailableSku.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/PurchasedSku.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/Sku.kt create mode 100644 app/src/gplay/res/values/strings.xml create mode 100644 app/src/main/java/eu/darken/capod/common/ColorExtensions.kt create mode 100644 app/src/main/java/eu/darken/capod/common/serialization/JavaInstantAdapter.kt create mode 100644 app/src/main/java/eu/darken/capod/common/upgrade/UpgradeRepo.kt create mode 100644 app/src/main/res/drawable/ic_baseline_heart_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_stars_24.xml diff --git a/app/build.gradle b/app/build.gradle index fff137ae..5de4a648 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -76,15 +76,31 @@ android { proguardFiles proguardRulesRelease manifestPlaceholders = [bugsnagApiKey: bugsnagProps.getProperty("bugsnag.apikey", "")] } - applicationVariants.all { variant -> - if (variant.buildType.name == "debug") { - variant.mergedFlavor.resourceConfigurations.clear() - variant.mergedFlavor.resourceConfigurations.add("en") - variant.mergedFlavor.resourceConfigurations.add("de") - } else if (variant.buildType.name != "debug") { - variant.outputs.each { output -> - output.outputFileName = "${packageName}-v" + defaultConfig.versionName + "(" + defaultConfig.versionCode + ")-" + variant.buildType.name.toUpperCase() + "-" + gitSha + ".apk" - } + } + + flavorDimensions "version" + productFlavors { + gplay { + + } + foss { + + } + } + + applicationVariants.all { variant -> + def flavor = variant.productFlavors[0].name.toUpperCase() + def buildType = variant.buildType.name.toUpperCase() + def versionCode = defaultConfig.versionCode + def versionName = defaultConfig.versionName + + if (variant.buildType.name == "debug") { + variant.mergedFlavor.resourceConfigurations.clear() + variant.mergedFlavor.resourceConfigurations.add("en") + variant.mergedFlavor.resourceConfigurations.add("de") + } else if (variant.buildType.name != "debug") { + variant.outputs.each { output -> + output.outputFileName = "${packageName}-v${versionName}(${versionCode})-${gitSha}-${flavor}-${buildType}.apk" } } } @@ -151,7 +167,7 @@ dependencies { } // Debugging - implementation ('com.bugsnag:bugsnag-android:5.9.2') + implementation('com.bugsnag:bugsnag-android:5.9.2') implementation 'com.getkeepsafe.relinker:relinker:1.4.3' implementation("com.squareup.moshi:moshi:1.13.0") @@ -198,12 +214,16 @@ dependencies { implementation 'androidx.core:core-splashscreen:1.0.0-alpha02' + def work_version = "2.7.1" implementation "androidx.work:work-runtime:${work_version}" testImplementation "androidx.work:work-testing:${work_version}" implementation "androidx.work:work-runtime-ktx:${work_version}" implementation 'androidx.hilt:hilt-work:1.0.0' + // IAP + gplayImplementation 'com.android.billingclient:billing:4.0.0' + // UI implementation 'androidx.constraintlayout:constraintlayout:2.1.2' implementation 'com.google.android.material:material:1.6.0-alpha01' diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/UpgradeModule.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/UpgradeModule.kt new file mode 100644 index 00000000..29e8fec1 --- /dev/null +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/UpgradeModule.kt @@ -0,0 +1,17 @@ +package eu.darken.capod.common.upgrade + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import eu.darken.capod.common.upgrade.core.UpgradeControlFoss +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +abstract class UpgradeModule { + @Binds + @Singleton + abstract fun control(foss: UpgradeControlFoss): UpgradeRepo + +} \ No newline at end of file diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossCache.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossCache.kt new file mode 100644 index 00000000..83446f36 --- /dev/null +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossCache.kt @@ -0,0 +1,24 @@ +package eu.darken.capod.common.upgrade.core + +import android.content.Context +import com.squareup.moshi.Moshi +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.capod.common.preferences.createFlowPreference +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FossCache @Inject constructor( + @ApplicationContext context: Context, + moshi: Moshi +) { + + private val preferences = context.getSharedPreferences("settings_foss", Context.MODE_PRIVATE) + + val upgrade = preferences.createFlowPreference( + key = "foss.upgrade", + moshi = moshi, + defaultValue = null, + ) + +} \ No newline at end of file diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossUpgrade.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossUpgrade.kt new file mode 100644 index 00000000..febc77ec --- /dev/null +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossUpgrade.kt @@ -0,0 +1,18 @@ +package eu.darken.capod.common.upgrade.core + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.time.Instant + +@JsonClass(generateAdapter = true) +data class FossUpgrade( + val upgradedAt: Instant, + val reason: Reason +) { + @JsonClass(generateAdapter = false) + enum class Reason { + @Json(name = "foss.upgrade.reason.donated") DONATED, + @Json(name = "foss.upgrade.reason.alreadydonated") ALREADY_DONATED, + @Json(name = "foss.upgrade.reason.nomoney") NO_MONEY; + } +} \ No newline at end of file diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/core/UpgradeControlFoss.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/core/UpgradeControlFoss.kt new file mode 100644 index 00000000..7daa1b64 --- /dev/null +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/core/UpgradeControlFoss.kt @@ -0,0 +1,71 @@ +package eu.darken.capod.common.upgrade.core + +import android.app.Activity +import android.widget.Toast +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import eu.darken.capod.R +import eu.darken.capod.common.WebpageTool +import eu.darken.capod.common.upgrade.UpgradeRepo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UpgradeControlFoss @Inject constructor( + private val fossCache: FossCache, + private val webpageTool: WebpageTool, +) : UpgradeRepo { + + override val upgradeInfo: Flow = fossCache.upgrade.flow.map { data -> + if (data == null) { + Info() + } else { + Info( + isPro = true, + upgradedAt = data.upgradedAt, + upgradeReason = data.reason + ) + } + } + + override fun launchBillingFlow(activity: Activity) { + MaterialAlertDialogBuilder(activity).apply { + setIcon(R.drawable.ic_heart) + setTitle(R.string.upgrade_capod_label) + setMessage(R.string.upgrade_capod_description) + setPositiveButton(R.string.foss_upgrade_donate_label) { _, _ -> + fossCache.upgrade.value = FossUpgrade( + upgradedAt = Instant.now(), + reason = FossUpgrade.Reason.DONATED + ) + webpageTool.open("https://github.com/d4rken/capod") + Toast.makeText(activity, R.string.general_thank_you_label, Toast.LENGTH_SHORT).show() + } + setNegativeButton(R.string.foss_upgrade_alreadydonated_label) { _, _ -> + fossCache.upgrade.value = FossUpgrade( + upgradedAt = Instant.now(), + reason = FossUpgrade.Reason.ALREADY_DONATED + ) + Toast.makeText(activity, R.string.general_thank_you_label, Toast.LENGTH_SHORT).show() + } + setNeutralButton(R.string.foss_upgrade_no_money_label) { _, _ -> + fossCache.upgrade.value = FossUpgrade( + upgradedAt = Instant.now(), + reason = FossUpgrade.Reason.NO_MONEY + ) + Toast.makeText(activity, "¯\\_(ツ)_/¯", Toast.LENGTH_SHORT).show() + } + }.show() + } + + data class Info( + override val isPro: Boolean = false, + override val upgradedAt: Instant? = null, + val upgradeReason: FossUpgrade.Reason? = null, + ) : UpgradeRepo.Info { + override val type: UpgradeRepo.Type = UpgradeRepo.Type.FOSS + } + +} \ No newline at end of file diff --git a/app/src/foss/res/values/strings.xml b/app/src/foss/res/values/strings.xml new file mode 100644 index 00000000..437c73a2 --- /dev/null +++ b/app/src/foss/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Donate + I already donated + I spend all my money on AirPods + \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/UpgradeModule.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/UpgradeModule.kt new file mode 100644 index 00000000..6b1c609f --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/UpgradeModule.kt @@ -0,0 +1,17 @@ +package eu.darken.capod.common.upgrade + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +abstract class UpgradeModule { + @Binds + @Singleton + abstract fun control(gplay: UpgradeRepoGplay): UpgradeRepo + +} \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt new file mode 100644 index 00000000..df657806 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt @@ -0,0 +1,20 @@ +package eu.darken.capod.common.upgrade.core + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.capod.common.preferences.createFlowPreference +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BillingCache @Inject constructor( + @ApplicationContext private val context: Context, +) { + + private val preferences = context.getSharedPreferences("settings_gplay", Context.MODE_PRIVATE) + + val lastProStateAt = preferences.createFlowPreference( + "gplay.cache.lastProAt", + 0L + ) +} diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingExtensions.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingExtensions.kt new file mode 100644 index 00000000..eafeca20 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingExtensions.kt @@ -0,0 +1,13 @@ +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 = skus.map { + PurchasedSku(Sku(it), this) +} + +private val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Extensions") diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/CapodSku.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/CapodSku.kt new file mode 100644 index 00000000..8520928f --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/CapodSku.kt @@ -0,0 +1,9 @@ +package eu.darken.capod.common.upgrade.core + +import eu.darken.capod.common.BuildConfigWrap +import eu.darken.capod.common.upgrade.core.data.AvailableSku +import eu.darken.capod.common.upgrade.core.data.Sku + +enum class CapodSku constructor(override val sku: Sku) : AvailableSku { + PRO_UPGRADE(Sku("${BuildConfigWrap.APPLICATION_ID}.iap.upgrade.pro")) +} \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt new file mode 100644 index 00000000..5ad3cc53 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt @@ -0,0 +1,124 @@ +package eu.darken.capod.common.upgrade.core + +import android.app.Activity +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import eu.darken.capod.R +import eu.darken.capod.common.coroutine.AppScope +import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.capod.common.debug.logging.asLog +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.error.asErrorDialogBuilder +import eu.darken.capod.common.flow.replayingShare +import eu.darken.capod.common.upgrade.UpgradeRepo +import eu.darken.capod.common.upgrade.core.data.BillingData +import eu.darken.capod.common.upgrade.core.data.BillingDataRepo +import eu.darken.capod.common.upgrade.core.data.PurchasedSku +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UpgradeRepoGplay @Inject constructor( + private val billingDataRepo: BillingDataRepo, + private val billingCache: BillingCache, + @AppScope private val scope: CoroutineScope, +) : UpgradeRepo { + + private var lastProStateAt: Long + get() = billingCache.lastProStateAt.value + set(value) = billingCache.lastProStateAt.update { value } + + override val upgradeInfo: Flow = billingDataRepo.billingData + .map { data -> // Only relinquish pro state if we haven't had it for a while + val now = System.currentTimeMillis() + val proSku = data.getProSku() + log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, data=${data}" } + when { + proSku != null -> { + // If we are pro refresh timestamp + lastProStateAt = now + Info(billingData = data) + } + (now - lastProStateAt) < 6 * 60 * 1000L -> { // 6 hours + log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" } + Info(gracePeriod = true, billingData = null) + } + else -> { + Info(billingData = data) + } + } + } + .catch { + // Ignore Google Play errors if the last pro state was recent + val now = System.currentTimeMillis() + log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, error=${it.toString()}" } + if ((now - lastProStateAt) < 6 * 60 * 60 * 1000L) { // 6 hours + log(TAG, VERBOSE) { "We are not pro, but were recently, and just and an error, what is GPlay doing???" } + emit(Info(gracePeriod = true, billingData = null)) + } else { + throw it + } + } + .replayingShare(scope) + + override fun launchBillingFlow(activity: Activity) { + MaterialAlertDialogBuilder(activity).apply { + setIcon(R.drawable.ic_heart) + setTitle(R.string.upgrade_capod_label) + setMessage(R.string.upgrade_capod_description) + setPositiveButton(R.string.general_upgrade_action) { _, _ -> + scope.launch { + try { + billingDataRepo.startIapFlow(activity, CapodSku.PRO_UPGRADE.sku) + } catch (e: Exception) { + log(TAG) { "startIapFlow failed:${e.asLog()}" } + e.asErrorDialogBuilder(activity).show() + } + } + } + setNeutralButton(R.string.general_check_action) { _, _ -> + log(TAG) { "recheck()" } + scope.launch { + try { + val data = billingDataRepo.getIapData() + log(TAG) { "Recheck successful: $data" } + } catch (e: Exception) { + log(TAG) { "Recheck failed:${e.asLog()}" } + e.asErrorDialogBuilder(activity).show() + } + } + } + }.show() + } + + data class Info( + private val gracePeriod: Boolean = false, + private val billingData: BillingData?, + ) : UpgradeRepo.Info { + + override val type: UpgradeRepo.Type + get() = UpgradeRepo.Type.GPLAY + + override val isPro: Boolean + get() = billingData?.getProSku() != null + + override val upgradedAt: Instant? + get() = billingData + ?.getProSku() + ?.purchase?.purchaseTime + ?.let { Instant.ofEpochMilli(it) } + } + + companion object { + private fun BillingData.getProSku(): PurchasedSku? = purchasedSkus + .firstOrNull { it.sku == CapodSku.PRO_UPGRADE.sku } + + val TAG: String = logTag("Upgrade", "Gplay", "Control") + } +} \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt new file mode 100644 index 00000000..9a6c82ea --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt @@ -0,0 +1,109 @@ +package eu.darken.capod.common.upgrade.core.client + +import android.app.Activity +import com.android.billingclient.api.* +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.flow.setupCommonEventHandlers +import eu.darken.capod.common.upgrade.core.data.Sku +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +data class BillingClientConnection( + private val client: BillingClient, + private val purchasesGlobal: Flow>, +) { + private val purchasesLocal = MutableStateFlow>(emptySet()) + val purchases: Flow> = combine( + purchasesGlobal, + purchasesLocal + ) { global, local -> + // TODO how to prevent duplicates? + global.plus(local) + } + .setupCommonEventHandlers(TAG) { "purchases" } + + suspend fun queryPurchases(): Collection { + val (result: BillingResult, purchases) = suspendCoroutine?>> { continuation -> + client.queryPurchasesAsync(BillingClient.SkuType.INAPP) { result, purchases -> + continuation.resume(result to purchases) + } + } + + log(TAG) { "queryPurchases(): code=${result.responseCode}, message=${result.debugMessage}, purchases=$purchases" } + + if (!result.isSuccess) { + log(TAG, WARN) { "queryPurchases() failed" } + throw BillingClientException(result) + } else { + requireNotNull(purchases) + } + + purchasesLocal.value = purchases + return purchases + } + + suspend fun acknowledgePurchase(purchase: Purchase): BillingResult { + val ack = AcknowledgePurchaseParams.newBuilder().apply { + setPurchaseToken(purchase.purchaseToken) + }.build() + + val ackResult = suspendCoroutine { continuation -> + client.acknowledgePurchase(ack) { continuation.resume(it) } + } + log(TAG) { + "acknowledgePurchase(purchase=$purchase): code=${ackResult.responseCode}, message=${ackResult.debugMessage})" + } + + if (!ackResult.isSuccess) { + throw BillingClientException(ackResult) + } + + return ackResult + } + + suspend fun querySku(sku: Sku): Sku.Details { + val skuParams = SkuDetailsParams.newBuilder().apply { + setType(BillingClient.SkuType.INAPP) + setSkusList(listOf(sku.id)) + }.build() + + val (result, details) = suspendCoroutine?>> { continuation -> + client.querySkuDetailsAsync(skuParams) { skuResult, skuDetails -> + continuation.resume(skuResult to skuDetails) + } + } + + log(TAG) { + "querySku(sku=$sku): code=${result.responseCode}, debug=${result.debugMessage}), skuDetails=$details" + } + + if (!result.isSuccess) throw BillingClientException(result) + + if (details.isNullOrEmpty()) throw IllegalStateException("Unknown SKU, no details available.") + + return Sku.Details(sku, details) + } + + suspend fun launchBillingFlow(activity: Activity, sku: Sku): BillingResult { + log(TAG) { "launchBillingFlow(activity=$activity, sku=$sku)" } + val skuDetails = querySku(sku) + return launchBillingFlow(activity, skuDetails) + } + + suspend fun launchBillingFlow(activity: Activity, skuDetails: Sku.Details): BillingResult { + log(TAG) { "launchBillingFlow(activity=$activity, skuDetails=$skuDetails)" } + return client.launchBillingFlow( + activity, + BillingFlowParams.newBuilder().setSkuDetails(skuDetails.details.single()).build() + ) + } + + companion object { + val TAG: String = logTag("Upgrade", "Gplay", "Billing", "ClientConnection") + } +} \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt new file mode 100644 index 00000000..169fba4f --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt @@ -0,0 +1,119 @@ +package eu.darken.capod.common.upgrade.core.client + +import android.content.Context +import com.android.billingclient.api.BillingClient.* +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.Purchase +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.capod.common.debug.logging.Logging.Priority.* +import eu.darken.capod.common.debug.logging.asLog +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.flow.setupCommonEventHandlers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.retryWhen +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@Singleton +class BillingClientConnectionProvider @Inject constructor( + @ApplicationContext private val context: Context, +) { + + private val connectionProvider: Flow = callbackFlow { + val purchasePublisher = MutableStateFlow>(emptySet()) + + val client = newBuilder(context).apply { + enablePendingPurchases() + setListener { result, purchases -> + if (result.isSuccess) { + log(TAG) { + "onPurchasesUpdated(code=${result.responseCode}, message=${result.debugMessage}, purchases=$purchases)" + } + purchasePublisher.value = purchases.orEmpty() + } else { + log(TAG, WARN) { + "error: onPurchasesUpdated(code=${result.responseCode}, message=${result.debugMessage}, purchases=$purchases)" + } + } + } + }.build() + + val connectionResult = suspendCoroutine { continuation -> + log(TAG, VERBOSE) { "startConnection(...)" } + client.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) { + log(TAG, VERBOSE) { + "onBillingSetupFinished(code=${result.responseCode}, message=${result.debugMessage})" + } + continuation.resume(result) + } + + override fun onBillingServiceDisconnected() { + log(TAG, VERBOSE) { "onBillingServiceDisconnected() " } + close(CancellationException("Billing service disconnected")) + } + }) + } + + val billingClientConnection = when (connectionResult.responseCode) { + BillingResponseCode.OK -> BillingClientConnection(client, purchasePublisher) + else -> throw BillingClientException(connectionResult) + } + + try { + purchasePublisher.value = billingClientConnection.queryPurchases() + log(TAG) { "Initial IAP query successful." } + } catch (e: Exception) { + log(TAG, ERROR) { "Initial IAP query failed:\n${e.asLog()}" } + } + + send(billingClientConnection) + + log(TAG) { "Awaiting close." } + awaitClose { + log(TAG) { "Stopping billing client connection" } + client.endConnection() + } + } + + val connection: Flow = connectionProvider + .setupCommonEventHandlers(TAG) { "connection" } + .retryWhen { cause, attempt -> + if (cause is CancellationException) { + log(TAG) { "BillingClient connection cancelled." } + return@retryWhen false + } + if (attempt > 5) { + log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" } + 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) { + log(TAG) { "Got BILLING_UNAVAILABLE while trying to connect client." } + return@retryWhen false + } + + log(TAG) { "Will retry BillingClient connection... *sigh*" } + delay(3000 * attempt) + true + } + + companion object { + val TAG: String = logTag("Upgrade", "Gplay", "Billing", "ClientProvider") + } +} \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientException.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientException.kt new file mode 100644 index 00000000..44f3b948 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientException.kt @@ -0,0 +1,11 @@ +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})" +} \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientExtensions.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientExtensions.kt new file mode 100644 index 00000000..65e1bbef --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientExtensions.kt @@ -0,0 +1,7 @@ +package eu.darken.capod.common.upgrade.core.client + +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingResult + +internal val BillingResult.isSuccess: Boolean + get() = responseCode == BillingClient.BillingResponseCode.OK \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/GplayServiceUnavailableException.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/GplayServiceUnavailableException.kt new file mode 100644 index 00000000..8c0ad30c --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/GplayServiceUnavailableException.kt @@ -0,0 +1,19 @@ +package eu.darken.capod.common.upgrade.core.client + +import android.content.Context +import eu.darken.capod.R +import eu.darken.capod.common.error.HasLocalizedError +import eu.darken.capod.common.error.LocalizedError + +class GplayServiceUnavailableException(cause: Throwable) : Exception("Google Play services are unavailable.", cause), + HasLocalizedError { + override fun getLocalizedError(context: Context): LocalizedError { + return LocalizedError( + throwable = this, + label = "Google Play Services Unavailable", + description = context.getString(R.string.upgrades_iap_gplay_unavailable_error) + ) + } + + +} \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/AvailableSku.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/AvailableSku.kt new file mode 100644 index 00000000..b4b5d25d --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/AvailableSku.kt @@ -0,0 +1,5 @@ +package eu.darken.capod.common.upgrade.core.data + +interface AvailableSku { + val sku: Sku +} \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt new file mode 100644 index 00000000..2b941374 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt @@ -0,0 +1,11 @@ +package eu.darken.capod.common.upgrade.core.data + +import com.android.billingclient.api.Purchase +import eu.darken.capod.common.upgrade.core.toPurchasedSku + +data class BillingData( + val purchases: Collection +) { + val purchasedSkus: Collection + get() = purchases.map { it.toPurchasedSku() }.flatten() +} \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt new file mode 100644 index 00000000..cbbd0ad7 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt @@ -0,0 +1,139 @@ +package eu.darken.capod.common.upgrade.core.data + +import android.app.Activity +import com.android.billingclient.api.* +import com.android.billingclient.api.BillingClient.* +import eu.darken.capod.common.coroutine.AppScope +import eu.darken.capod.common.debug.Bugs +import eu.darken.capod.common.debug.logging.Logging.Priority.* +import eu.darken.capod.common.debug.logging.asLog +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.flow.replayingShare +import eu.darken.capod.common.flow.setupCommonEventHandlers +import eu.darken.capod.common.upgrade.core.client.BillingClientConnectionProvider +import eu.darken.capod.common.upgrade.core.client.BillingClientException +import eu.darken.capod.common.upgrade.core.client.GplayServiceUnavailableException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BillingDataRepo @Inject constructor( + billingClientConnectionProvider: BillingClientConnectionProvider, + @AppScope private val scope: CoroutineScope, +) { + + private val connectionProvider = billingClientConnectionProvider.connection + .replayingShare(scope) + private val purchaseData = connectionProvider.flatMapLatest { it.purchases } + + val billingData: Flow = purchaseData + .map { + BillingData( + purchases = it + ) + } + .setupCommonEventHandlers(TAG) { "iapData" } + .replayingShare(scope) + + init { + connectionProvider + .flatMapLatest { client -> + client.purchases.map { client to it } + } + .onEach { (client, purchases) -> + purchases + .filter { + val needsAck = !it.isAcknowledged + + if (needsAck) log(TAG, INFO) { "Needs ACK: $it" } + else log(TAG) { "Already ACK'ed: $it" } + + needsAck + } + .forEach { + log(TAG, INFO) { "Acknowledging purchase: $it" } + + try { + client.acknowledgePurchase(it) + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to ancknowledge purchase: $it\n${e.asLog()}" } + } + } + } + .setupCommonEventHandlers(TAG) { "connection-acks" } + .retryWhen { cause, attempt -> + if (cause is CancellationException) { + log(TAG) { "Ack was cancelled (appScope?) cancelled." } + return@retryWhen false + } + if (attempt > 5) { + log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" } + return@retryWhen false + } + if (cause !is BillingClientException) { + log(TAG, WARN) { "Unknown BillingClient exception type: $cause" } + return@retryWhen false + } else { + log(TAG) { "BillingClient exception: $cause; ${cause.result}" } + } + + if (cause.result.responseCode == BillingResponseCode.BILLING_UNAVAILABLE) { + log(TAG) { "Got BILLING_UNAVAILABLE while trying to ACK purchase." } + return@retryWhen false + } + + log(TAG) { "Will retry ACK" } + delay(3000 * attempt) + true + } + .launchIn(scope) + } + + suspend fun getIapData(): BillingData = try { + val clientConnection = connectionProvider.first() + val iaps = clientConnection.queryPurchases() + + BillingData( + purchases = iaps + ) + } catch (e: Exception) { + throw e.tryMapUserFriendly() + } + + suspend fun startIapFlow(activity: Activity, sku: Sku) { + try { + val clientConnection = connectionProvider.first() + clientConnection.launchBillingFlow(activity, sku) + } catch (e: Exception) { + log(TAG, WARN) { "Failed to start IAP flow:\n${e.asLog()}" } + val ignoredCodes = listOf(3, 6) + if (e !is BillingClientException || !e.result.responseCode.let { ignoredCodes.contains(it) }) { + Bugs.report(TAG, "IAP flow failed for $sku", e) + } + + throw e.tryMapUserFriendly() + } + } + + companion object { + val TAG: String = logTag("Upgrade", "Gplay", "Billing", "DataRepo") + + internal fun Throwable.tryMapUserFriendly(): Throwable { + if (this !is BillingClientException) return this + + return when (result.responseCode) { + BillingResponseCode.BILLING_UNAVAILABLE, + BillingResponseCode.SERVICE_UNAVAILABLE, + BillingResponseCode.SERVICE_DISCONNECTED, + BillingResponseCode.SERVICE_TIMEOUT -> GplayServiceUnavailableException(this) + else -> this + } + } + } +} diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/PurchasedSku.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/PurchasedSku.kt new file mode 100644 index 00000000..cdb7a43b --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/PurchasedSku.kt @@ -0,0 +1,8 @@ +package eu.darken.capod.common.upgrade.core.data + +import com.android.billingclient.api.Purchase +import eu.darken.capod.common.upgrade.core.data.Sku + +data class PurchasedSku(val sku: Sku, val purchase: Purchase) { + override fun toString(): String = "IAP(sku=$sku, purchase=${purchase.skus})" +} \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/Sku.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/Sku.kt new file mode 100644 index 00000000..2ab5ce9c --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/Sku.kt @@ -0,0 +1,12 @@ +package eu.darken.capod.common.upgrade.core.data + +import com.android.billingclient.api.SkuDetails + +data class Sku( + val id: String +) { + data class Details( + val sku: Sku, + val details: Collection, + ) +} \ No newline at end of file diff --git a/app/src/gplay/res/values/strings.xml b/app/src/gplay/res/values/strings.xml new file mode 100644 index 00000000..68ba2a67 --- /dev/null +++ b/app/src/gplay/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Google Play services are unavailable. + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 391d14dd..d79e1632 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,27 +3,30 @@ xmlns:tools="http://schemas.android.com/tools" package="eu.darken.capod"> + + + + + - - - - - - + + + + + + fun launchBillingFlow(activity: Activity) + + interface Info { + val type: Type + + val isPro: Boolean + + val upgradedAt: Instant? + } + + enum class Type { + GPLAY, + FOSS + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragment.kt index 629f7db7..cf21e6f9 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragment.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragment.kt @@ -4,17 +4,20 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.Settings +import android.text.SpannableStringBuilder import android.view.View import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import eu.darken.capod.R +import eu.darken.capod.common.colorString import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.lists.differ.update import eu.darken.capod.common.lists.setupDefaults import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.uix.Fragment3 +import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.viewbinding.viewBinding import eu.darken.capod.databinding.MainFragmentBinding import javax.inject.Inject @@ -54,6 +57,14 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) { vm.goToSettings() true } + R.id.menu_item_donate -> { + vm.onUpgrade() + true + } + R.id.menu_item_upgrade -> { + vm.onUpgrade() + true + } else -> false } } @@ -76,6 +87,48 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) { permissionLauncher.launch(it.permissionId) } } + + vm.upgradeState.observe2(ui) { info -> + val gplay = toolbar.menu.findItem(R.id.menu_item_upgrade) + val donate = toolbar.menu.findItem(R.id.menu_item_donate) + gplay.isVisible = false + donate.isVisible = false + + val baseTitle = when (info.type) { + UpgradeRepo.Type.GPLAY -> { + if (info.isPro) { + getString(R.string.app_name_pro) + } else { + gplay.isVisible = true + getString(R.string.app_name) + } + } + UpgradeRepo.Type.FOSS -> { + if (info.isPro) { + getString(R.string.app_name_foss) + } else { + donate.isVisible = true + getString(R.string.app_name) + } + } + }.split(" ".toRegex()) + .dropLastWhile { it.isEmpty() } + .toTypedArray() + + toolbar.title = if (baseTitle.size == 2) { + val builder = SpannableStringBuilder(baseTitle[0] + " ") + val color = when (info.type) { + UpgradeRepo.Type.FOSS -> R.color.brand_secondary + else -> R.color.brand_tertiary + } + builder.append(colorString(requireContext(), color, baseTitle[1])) + } else { + getString(R.string.app_name) + } + } + vm.launchUpgradeFlow.observe2 { + it(requireActivity()) + } super.onViewCreated(view, savedInstanceState) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt index 57ccb04c..bb2f01c4 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt @@ -1,5 +1,6 @@ package eu.darken.capod.main.ui.overview +import android.app.Activity import androidx.lifecycle.LiveData import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel @@ -10,6 +11,7 @@ import eu.darken.capod.common.livedata.SingleLiveEvent import eu.darken.capod.common.navigation.navVia import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.uix.ViewModel3 +import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.PermissionTool @@ -39,8 +41,12 @@ class OverviewFragmentVM @Inject constructor( private val permissionTool: PermissionTool, private val generalSettings: GeneralSettings, debugSettings: DebugSettings, + private val upgradeRepo: UpgradeRepo, ) : ViewModel3(dispatcherProvider = dispatcherProvider) { + val upgradeState = upgradeRepo.upgradeInfo.asLiveData2() + val launchUpgradeFlow = SingleLiveEvent<(Activity) -> Unit>() + private val updateTicker = channelFlow { while (isActive) { trySend(Unit) @@ -153,4 +159,11 @@ class OverviewFragmentVM @Inject constructor( OverviewFragmentDirections.actionOverviewFragmentToSettingsFragment().navVia(this@OverviewFragmentVM) } + fun onUpgrade() = launch { + val call: (Activity) -> Unit = { + upgradeRepo.launchBillingFlow(it) + } + launchUpgradeFlow.postValue(call) + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt index 3ab89476..a484d5ad 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt @@ -68,8 +68,7 @@ class MonitorWorker @AssistedInject constructor( Result.success(inputData) } catch (e: Throwable) { - log(TAG, ERROR) { "Execution failed:\n${e.asLog()}" } - Bugs.report(e) + Bugs.report(tag = TAG, "Execution failed", exception = e) finishedWithError = true Result.failure(inputData) } finally { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/AppleFactory.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/AppleFactory.kt index 74a77f76..3dc44e84 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/AppleFactory.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/AppleFactory.kt @@ -215,8 +215,11 @@ class AppleFactory @Inject constructor( ) else -> { - log(TAG, WARN) { "Unknown proximity message type" } - Bugs.report(IllegalArgumentException("Unknown ProximityMessage: $pm")) + Bugs.report( + tag = TAG, + message = "Unknown proximity message type", + exception = IllegalArgumentException("Unknown ProximityMessage: $pm") + ) UnknownAppleDevice( identifier = identifier, scanResult = scanResult, diff --git a/app/src/main/res/drawable/ic_baseline_heart_24.xml b/app/src/main/res/drawable/ic_baseline_heart_24.xml new file mode 100644 index 00000000..cff844ad --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_heart_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_stars_24.xml b/app/src/main/res/drawable/ic_baseline_stars_24.xml new file mode 100644 index 00000000..a9052411 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_stars_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml index 580eaf59..50613f0c 100644 --- a/app/src/main/res/menu/main.xml +++ b/app/src/main/res/menu/main.xml @@ -1,6 +1,21 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tool="http://schemas.android.com/tools"> + + CAPod CAPod Pro + CAPod FOSS Device status @@ -12,6 +13,8 @@ Thank you N/A Grant permission + Upgrade + Check Size Compressed size @@ -65,6 +68,9 @@ No device No paired device connected. Connect a paired device or enable the \'Show all\' option. + Charging + In ear + Microphone Settings Privacy policy @@ -112,9 +118,6 @@ Show fake data, i.e. simulate device that don\'t exist. Debug settings Additional settings to help troubleshoot issues with the app. - Charging - In ear - Microphone Minimum signal quality The minimum signal quality that a device needs to have to be considered yours. Auto connect @@ -130,4 +133,7 @@ When seen Case is open In ear + + Upgrade CAPod + Get additional features and support the developer. \ No newline at end of file