Add Gplay and FOSS upgrade scaffolding

This commit is contained in:
darken
2022-01-16 22:37:03 +01:00
parent 3e6d914d55
commit 07842fa8df
38 changed files with 989 additions and 36 deletions
+30 -10
View File
@@ -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
}
}
+6
View File
@@ -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")
}
}
@@ -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")
}
}
@@ -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")
}
}
@@ -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})"
}
@@ -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
@@ -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>,
)
}
+4
View File
@@ -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>
+11 -8
View File
@@ -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>
+16 -1
View File
@@ -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"
+9 -3
View File
@@ -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>