mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Add Gplay and FOSS upgrade scaffolding
This commit is contained in:
+30
-10
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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<FossUpgrade?>(
|
||||
key = "foss.upgrade",
|
||||
moshi = moshi,
|
||||
defaultValue = null,
|
||||
)
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<UpgradeRepo.Info> = 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="foss_upgrade_donate_label">Donate</string>
|
||||
<string name="foss_upgrade_alreadydonated_label">I already donated</string>
|
||||
<string name="foss_upgrade_no_money_label">I spend all my money on AirPods</string>
|
||||
</resources>
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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<PurchasedSku> = skus.map {
|
||||
PurchasedSku(Sku(it), this)
|
||||
}
|
||||
|
||||
private val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Extensions")
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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<UpgradeRepo.Info> = 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")
|
||||
}
|
||||
}
|
||||
+109
@@ -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<Collection<Purchase>>,
|
||||
) {
|
||||
private val purchasesLocal = MutableStateFlow<Collection<Purchase>>(emptySet())
|
||||
val purchases: Flow<Collection<Purchase>> = combine(
|
||||
purchasesGlobal,
|
||||
purchasesLocal
|
||||
) { global, local ->
|
||||
// TODO how to prevent duplicates?
|
||||
global.plus(local)
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "purchases" }
|
||||
|
||||
suspend fun queryPurchases(): Collection<Purchase> {
|
||||
val (result: BillingResult, purchases) = suspendCoroutine<Pair<BillingResult, Collection<Purchase>?>> { 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<BillingResult> { continuation ->
|
||||
client.acknowledgePurchase(ack) { continuation.resume(it) }
|
||||
}
|
||||
log(TAG) {
|
||||
"acknowledgePurchase(purchase=$purchase): code=${ackResult.responseCode}, message=${ackResult.debugMessage})"
|
||||
}
|
||||
|
||||
if (!ackResult.isSuccess) {
|
||||
throw BillingClientException(ackResult)
|
||||
}
|
||||
|
||||
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<Pair<BillingResult, Collection<SkuDetails>?>> { 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")
|
||||
}
|
||||
}
|
||||
+119
@@ -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<BillingClientConnection> = callbackFlow {
|
||||
val purchasePublisher = MutableStateFlow<Collection<Purchase>>(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<BillingResult> { continuation ->
|
||||
log(TAG, VERBOSE) { "startConnection(...)" }
|
||||
client.startConnection(object : BillingClientStateListener {
|
||||
override fun onBillingSetupFinished(result: BillingResult) {
|
||||
log(TAG, VERBOSE) {
|
||||
"onBillingSetupFinished(code=${result.responseCode}, message=${result.debugMessage})"
|
||||
}
|
||||
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<BillingClientConnection> = 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")
|
||||
}
|
||||
}
|
||||
+11
@@ -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})"
|
||||
}
|
||||
+7
@@ -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
|
||||
+19
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.darken.capod.common.upgrade.core.data
|
||||
|
||||
interface AvailableSku {
|
||||
val sku: Sku
|
||||
}
|
||||
@@ -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<Purchase>
|
||||
) {
|
||||
val purchasedSkus: Collection<PurchasedSku>
|
||||
get() = purchases.map { it.toPurchasedSku() }.flatten()
|
||||
}
|
||||
@@ -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<BillingData> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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<SkuDetails>,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="upgrades_iap_gplay_unavailable_error">Google Play services are unavailable.</string>
|
||||
</resources>
|
||||
@@ -3,27 +3,30 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="eu.darken.capod">
|
||||
|
||||
<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
|
||||
<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth_le"
|
||||
|
||||
@@ -6,13 +6,15 @@ import eu.darken.capod.BuildConfig
|
||||
// Can't be const because that prevents them from being mocked in tests
|
||||
@Suppress("MayBeConstant")
|
||||
object BuildConfigWrap {
|
||||
val APPLICATION_ID = BuildConfig.APPLICATION_ID
|
||||
val DEBUG: Boolean = BuildConfig.DEBUG
|
||||
val FLAVOR: String = BuildConfig.FLAVOR
|
||||
val BUILD_TYPE: String = BuildConfig.BUILD_TYPE
|
||||
val DEBUG: Boolean = BuildConfig.DEBUG
|
||||
|
||||
val APPLICATION_ID = BuildConfig.APPLICATION_ID
|
||||
|
||||
val VERSION_CODE: Long = BuildConfig.VERSION_CODE.toLong()
|
||||
val VERSION_NAME: String = BuildConfig.VERSION_NAME
|
||||
val GIT_SHA: String = BuildConfig.GITSHA
|
||||
|
||||
val VERSION_DESCRIPTION: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA]"
|
||||
val VERSION_DESCRIPTION: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA] ${FLAVOR}_$BUILD_TYPE"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import android.content.Context
|
||||
import android.text.SpannableString
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
fun colorString(context: Context, @ColorRes colorRes: Int, string: String): SpannableString {
|
||||
val colored = SpannableString(string)
|
||||
colored.setSpan(ForegroundColorSpan(ContextCompat.getColor(context, colorRes)), 0, string.length, 0)
|
||||
return colored
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
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.pods.core.apple.protocol.ContinuityProtocol
|
||||
@@ -172,8 +171,7 @@ class BluetoothManager2 @Inject constructor(
|
||||
log(TAG) { "Nudged connection to $device" }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "BluetoothHeadset.connect(device) is unavailable:\n${e.asLog()}" }
|
||||
Bugs.report(e)
|
||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package eu.darken.capod.common.debug
|
||||
|
||||
import com.bugsnag.android.Bugsnag
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
|
||||
object Bugs {
|
||||
var ready = false
|
||||
fun report(exception: Throwable) {
|
||||
fun report(
|
||||
tag: String,
|
||||
message: String,
|
||||
exception: Throwable
|
||||
) {
|
||||
log(TAG, VERBOSE) { "Reporting $exception" }
|
||||
log(tag, ERROR) { "$message\n${exception.asLog()}" }
|
||||
|
||||
if (!ready) {
|
||||
log(TAG, WARN) { "Bug tracking not initialized yet." }
|
||||
return
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package eu.darken.capod.common.serialization
|
||||
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.ToJson
|
||||
import java.time.Instant
|
||||
|
||||
class JavaInstantAdapter {
|
||||
@ToJson
|
||||
fun toJson(obj: Instant): Long = obj.toEpochMilli()
|
||||
|
||||
@FromJson
|
||||
fun fromJson(epochMillis: Long): Instant = Instant.ofEpochSecond(epochMillis)
|
||||
}
|
||||
@@ -13,6 +13,8 @@ class SerializationModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun moshi(): Moshi = Moshi.Builder().build()
|
||||
fun moshi(): Moshi = Moshi.Builder()
|
||||
.add(JavaInstantAdapter())
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package eu.darken.capod.common.upgrade
|
||||
|
||||
import android.app.Activity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.time.Instant
|
||||
|
||||
interface UpgradeRepo {
|
||||
val upgradeInfo: Flow<Info>
|
||||
|
||||
fun launchBillingFlow(activity: Activity)
|
||||
|
||||
interface Info {
|
||||
val type: Type
|
||||
|
||||
val isPro: Boolean
|
||||
|
||||
val upgradedAt: Instant?
|
||||
}
|
||||
|
||||
enum class Type {
|
||||
GPLAY,
|
||||
FOSS
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Unit> {
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M9.75,7.82C10.62,7.82 11.45,8.23 12,8.87C12.55,8.23 13.38,7.82 14.25,7.82C15.79,7.82 17,9.03 17,10.57C17,12.46 15.3,14 12.72,16.34L12,17L11.28,16.34C8.7,14 7,12.46 7,10.57C7,9.03 8.21,7.82 9.75,7.82Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM16.23,18L12,15.45 7.77,18l1.12,-4.81 -3.73,-3.23 4.92,-0.42L12,5l1.92,4.53 4.92,0.42 -3.73,3.23L16.23,18z" />
|
||||
</vector>
|
||||
@@ -1,6 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tool="http://schemas.android.com/tools">
|
||||
<item
|
||||
android:id="@+id/menu_item_donate"
|
||||
android:icon="@drawable/ic_baseline_heart_24"
|
||||
android:title="@string/settings_general_label"
|
||||
android:visible="false"
|
||||
tool:visible="true"
|
||||
app:showAsAction="always" />
|
||||
<item
|
||||
android:id="@+id/menu_item_upgrade"
|
||||
android:icon="@drawable/ic_baseline_stars_24"
|
||||
android:title="@string/settings_general_label"
|
||||
android:visible="false"
|
||||
tool:visible="true"
|
||||
app:showAsAction="always" />
|
||||
<item
|
||||
android:id="@+id/menu_item_settings"
|
||||
android:icon="@drawable/ic_baseline_settings_24"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<resources>
|
||||
<string name="app_name">CAPod</string>
|
||||
<string name="app_name_pro">CAPod Pro</string>
|
||||
<string name="app_name_foss">CAPod FOSS</string>
|
||||
|
||||
<string name="notification_channel_device_status_label">Device status</string>
|
||||
|
||||
@@ -12,6 +13,8 @@
|
||||
<string name="general_thank_you_label">Thank you</string>
|
||||
<string name="general_value_not_available_label">N/A</string>
|
||||
<string name="general_grant_permission_action">Grant permission</string>
|
||||
<string name="general_upgrade_action">Upgrade</string>
|
||||
<string name="general_check_action">Check</string>
|
||||
|
||||
<string name="debug_debuglog_size_label">Size</string>
|
||||
<string name="debug_debuglog_size_compressed_label">Compressed size</string>
|
||||
@@ -65,6 +68,9 @@
|
||||
<string name="pods_none_label_short">No device</string>
|
||||
<string name="pods_none_label">No paired device connected.</string>
|
||||
<string name="pods_none_description">Connect a paired device or enable the \'Show all\' option.</string>
|
||||
<string name="pods_charging_label">Charging</string>
|
||||
<string name="pods_inear_label">In ear</string>
|
||||
<string name="pods_microphone_label">Microphone</string>
|
||||
|
||||
<string name="settings_label">Settings</string>
|
||||
<string name="settings_privacy_policy_label">Privacy policy</string>
|
||||
@@ -112,9 +118,6 @@
|
||||
<string name="settings_fake_data_description">Show fake data, i.e. simulate device that don\'t exist.</string>
|
||||
<string name="settings_debug_label">Debug settings</string>
|
||||
<string name="settings_debug_description">Additional settings to help troubleshoot issues with the app.</string>
|
||||
<string name="pods_charging_label">Charging</string>
|
||||
<string name="pods_inear_label">In ear</string>
|
||||
<string name="pods_microphone_label">Microphone</string>
|
||||
<string name="settings_signal_minimum_label">Minimum signal quality</string>
|
||||
<string name="settings_signal_minimum_description">The minimum signal quality that a device needs to have to be considered yours.</string>
|
||||
<string name="settings_autoconnect_label">Auto connect</string>
|
||||
@@ -130,4 +133,7 @@
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">When seen</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">Case is open</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">In ear</string>
|
||||
|
||||
<string name="upgrade_capod_label">Upgrade CAPod</string>
|
||||
<string name="upgrade_capod_description">Get additional features and support the developer.</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user